Add export document pdf options

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-09-30 10:49:34 +02:00
parent f61ad48b59
commit 56a666b7e8
16 changed files with 814 additions and 61 deletions

View File

@@ -0,0 +1,164 @@
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
useDialogRef,
Spinner,
Checkbox,
} from "@probo/ui";
import { type ReactNode, useImperativeHandle, forwardRef } from "react";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { sprintf } from "@probo/helpers";
const bulkExportSchema = z.object({
withWatermark: z.boolean(),
watermarkEmail: z.string().optional().or(z.literal("")),
withSignatures: z.boolean(),
}).refine((data) => {
if (data.withWatermark && (!data.watermarkEmail || data.watermarkEmail === "")) {
return false;
}
if (data.withWatermark && data.watermarkEmail && !z.string().email().safeParse(data.watermarkEmail).success) {
return false;
}
return true;
}, {
message: "Please enter a valid email address",
path: ["watermarkEmail"],
});
type BulkExportFormData = z.infer<typeof bulkExportSchema>;
type Props = {
children: ReactNode;
onExport: (options: BulkExportFormData) => void;
isLoading?: boolean;
defaultEmail?: string;
selectedCount: number;
};
export type BulkExportDialogRef = {
open: () => void;
close: () => void;
};
export const BulkExportDialog = forwardRef<BulkExportDialogRef, Props>(
({ children, onExport, isLoading = false, defaultEmail = "", selectedCount }, ref) => {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const { register, handleSubmit, formState, watch, setValue } = useFormWithSchema(
bulkExportSchema,
{
defaultValues: {
withWatermark: false,
watermarkEmail: defaultEmail,
withSignatures: true,
},
}
);
const watchWatermark = watch("withWatermark");
const watchSignatures = watch("withSignatures");
useImperativeHandle(ref, () => ({
open: () => dialogRef.current?.open(),
close: () => dialogRef.current?.close(),
}));
const onSubmit = handleSubmit((data) => {
const options = {
...data,
watermarkEmail: data.withWatermark ? data.watermarkEmail : undefined,
};
onExport(options);
dialogRef.current?.close();
});
return (
<>
<div onClick={() => dialogRef.current?.open()}>{children}</div>
<Dialog
className="max-w-md"
ref={dialogRef}
title={sprintf(__("Export %s Documents"), selectedCount)}
>
<form onSubmit={onSubmit}>
<DialogContent className="space-y-4" padded>
<div className="space-y-4">
<div className="flex items-start gap-3">
<Checkbox
checked={watchSignatures}
onChange={(checked) => setValue("withSignatures", checked)}
/>
<div className="flex-1">
<label className="text-sm font-medium text-txt-primary cursor-pointer">
{__("Include signatures")}
</label>
<p className="text-xs text-txt-secondary mt-1">
{__("Show signature information and approval details in the PDFs")}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
checked={watchWatermark}
onChange={(checked) => setValue("withWatermark", checked)}
/>
<div className="flex-1">
<label className="text-sm font-medium text-txt-primary cursor-pointer">
{__("Add watermark")}
</label>
<p className="text-xs text-txt-secondary mt-1">
{__("Add confidential watermark with email and timestamp to all PDFs")}
</p>
</div>
</div>
{watchWatermark && (
<div className="ml-6">
<Field
label={__("Watermark email")}
{...register("watermarkEmail")}
type="email"
placeholder={__("Enter email address")}
error={formState.errors.watermarkEmail?.message}
autoComplete="off"
required
/>
</div>
)}
</div>
<div className="bg-level-1 p-3 rounded-lg border border-border-subtle">
<p className="text-sm text-txt-secondary">
{__("The documents will be exported as individual PDFs in a ZIP file. You will receive an email when the export is ready for download.")}
</p>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
disabled={isLoading}
>
{isLoading ? (
<>
<Spinner size={16} />
{__("Exporting...")}
</>
) : (
__("Export Documents")
)}
</Button>
</DialogFooter>
</form>
</Dialog>
</>
);
}
);

View File

@@ -0,0 +1,141 @@
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
useDialogRef,
Spinner,
Checkbox,
} from "@probo/ui";
import { type ReactNode, useImperativeHandle, forwardRef } from "react";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
const pdfDownloadSchema = z.object({
withWatermark: z.boolean(),
watermarkEmail: z.string().email("Please enter a valid email address").optional().or(z.literal("")),
withSignatures: z.boolean(),
});
type PdfDownloadFormData = z.infer<typeof pdfDownloadSchema>;
type Props = {
children: ReactNode;
onDownload: (options: PdfDownloadFormData) => void;
isLoading?: boolean;
defaultEmail?: string;
};
export type PdfDownloadDialogRef = {
open: () => void;
close: () => void;
};
export const PdfDownloadDialog = forwardRef<PdfDownloadDialogRef, Props>(
({ children, onDownload, isLoading = false, defaultEmail = "" }, ref) => {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const { register, handleSubmit, formState, watch, setValue } = useFormWithSchema(
pdfDownloadSchema,
{
defaultValues: {
withWatermark: false,
watermarkEmail: defaultEmail,
withSignatures: true,
},
}
);
const watchWatermark = watch("withWatermark");
const watchSignatures = watch("withSignatures");
useImperativeHandle(ref, () => ({
open: () => dialogRef.current?.open(),
close: () => dialogRef.current?.close(),
}));
const onSubmit = handleSubmit((data) => {
const options = {
...data,
watermarkEmail: data.withWatermark ? data.watermarkEmail : undefined,
};
onDownload(options);
dialogRef.current?.close();
});
return (
<>
<div onClick={() => dialogRef.current?.open()}>{children}</div>
<Dialog className="max-w-md" ref={dialogRef} title={__("Download PDF Options")}>
<form onSubmit={onSubmit}>
<DialogContent className="space-y-4" padded>
<div className="space-y-4">
<div className="flex items-start gap-3">
<Checkbox
checked={watchSignatures}
onChange={(checked) => setValue("withSignatures", checked)}
/>
<div className="flex-1">
<label className="text-sm font-medium text-txt-primary cursor-pointer">
{__("Include signatures")}
</label>
<p className="text-xs text-txt-secondary mt-1">
{__("Show signature information and approval details in the PDF")}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
checked={watchWatermark}
onChange={(checked) => setValue("withWatermark", checked)}
/>
<div className="flex-1">
<label className="text-sm font-medium text-txt-primary cursor-pointer">
{__("Add watermark")}
</label>
<p className="text-xs text-txt-secondary mt-1">
{__("Add confidential watermark with email and timestamp")}
</p>
</div>
</div>
{watchWatermark && (
<div className="ml-6">
<Field
label={__("Watermark email")}
{...register("watermarkEmail")}
type="email"
placeholder={__("Enter email address")}
error={formState.errors.watermarkEmail?.message}
autoComplete="off"
required
/>
</div>
)}
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
disabled={isLoading}
>
{isLoading ? (
<>
<Spinner size={16} />
{__("Downloading...")}
</>
) : (
__("Download PDF")
)}
</Button>
</DialogFooter>
</form>
</Dialog>
</>
);
}
);

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<a398c636bba3d35aa7caef66c62c9ac6>>
* @generated SignedSource<<9b84adc0253186c6f0a5f47837d83d7f>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -11,6 +11,9 @@
import { ConcreteRequest } from 'relay-runtime';
export type BulkExportDocumentsInput = {
documentIds: ReadonlyArray<string>;
watermarkEmail?: string | null | undefined;
withSignatures: boolean;
withWatermark: boolean;
};
export type DocumentGraphBulkExportDocumentsMutation$variables = {
input: BulkExportDocumentsInput;

View File

@@ -4,6 +4,7 @@ import {
loadQuery,
useFragment,
usePreloadedQuery,
useLazyLoadQuery,
} from "react-relay";
import type { DocumentGraphNodeQuery } from "/hooks/graph/__generated__/DocumentGraphNodeQuery.graphql";
import {
@@ -18,6 +19,7 @@ import type {
} from "./__generated__/DocumentDetailPageDocumentFragment.graphql";
import type { DocumentDetailPageExportPDFMutation } from "./__generated__/DocumentDetailPageExportPDFMutation.graphql";
import type { DocumentDetailPageUpdateMutation } from "./__generated__/DocumentDetailPageUpdateMutation.graphql";
import type { DocumentDetailPageUserEmailQuery } from "./__generated__/DocumentDetailPageUserEmailQuery.graphql";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -54,6 +56,7 @@ import {
useParams,
} from "react-router";
import UpdateVersionDialog from "./dialogs/UpdateVersionDialog";
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "/components/documents/PdfDownloadDialog";
import { useRef, useState } from "react";
import type { NodeOf } from "/types.ts";
import clsx from "clsx";
@@ -185,6 +188,16 @@ const documentUpdateSchema = z.object({
documentType: z.enum(documentTypes),
});
const UserEmailQuery = graphql`
query DocumentDetailPageUserEmailQuery {
viewer {
user {
email
}
}
}
`;
export default function DocumentDetailPage(props: Props) {
const { versionId } = useParams<{ versionId?: string }>();
const node = usePreloadedQuery(documentNodeQuery, props.queryRef).node;
@@ -223,6 +236,9 @@ export default function DocumentDetailPage(props: Props) {
errorMessage: __("Failed to generate PDF. Please try again."),
}
);
const userEmailData = useLazyLoadQuery<DocumentDetailPageUserEmailQuery>(UserEmailQuery, {});
const defaultEmail = userEmailData.viewer.user.email;
const [updateDocument, isUpdatingDocument] = useMutationWithToasts<DocumentDetailPageUpdateMutation>(
updateDocumentMutation,
{
@@ -360,11 +376,16 @@ export default function DocumentDetailPage(props: Props) {
);
};
const handleDownloadPdf = () => {
const handleDownloadPdf = (options: { withWatermark: boolean; withSignatures: boolean; watermarkEmail?: string }) => {
const input = {
documentVersionId: currentVersion.id,
withWatermark: options.withWatermark,
withSignatures: options.withSignatures,
...(options.withWatermark && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
};
exportDocumentVersionPDF({
variables: {
input: { documentVersionId: currentVersion.id },
},
variables: { input },
onCompleted: (data) => {
if (data.exportDocumentVersionPDF?.data) {
const link = window.document.createElement("a");
@@ -379,6 +400,7 @@ export default function DocumentDetailPage(props: Props) {
};
const updateDialogRef = useRef<{ open: () => void }>(null);
const pdfDownloadDialogRef = useRef<PdfDownloadDialogRef>(null);
const controlsCount = document.controlsInfo.totalCount;
const urlPrefix = versionId
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
@@ -391,6 +413,14 @@ export default function DocumentDetailPage(props: Props) {
document={document}
connectionId={versionConnectionId}
/>
<PdfDownloadDialog
ref={pdfDownloadDialogRef}
onDownload={handleDownloadPdf}
isLoading={isExporting}
defaultEmail={defaultEmail}
>
{null}
</PdfDownloadDialog>
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<Breadcrumb
@@ -451,7 +481,7 @@ export default function DocumentDetailPage(props: Props) {
</DropdownItem>
)}
<DropdownItem
onClick={handleDownloadPdf}
onClick={() => pdfDownloadDialogRef.current?.open()}
icon={IconArrowDown}
disabled={isExporting}
>

View File

@@ -26,8 +26,10 @@ import {
useFragment,
usePaginationFragment,
usePreloadedQuery,
useLazyLoadQuery,
type PreloadedQuery,
} from "react-relay";
import { useRef } from "react";
import { graphql } from "relay-runtime";
import type { DocumentGraphListQuery } from "/hooks/graph/__generated__/DocumentGraphListQuery.graphql";
import {
@@ -45,6 +47,8 @@ import type { DocumentsPageRowFragment$key } from "./__generated__/DocumentsPage
import { SortableTable, SortableTh } from "/components/SortableTable";
import { PublishDocumentsDialog } from "./dialogs/PublishDocumentsDialog.tsx";
import { SignatureDocumentsDialog } from "./dialogs/SignatureDocumentsDialog.tsx";
import { BulkExportDialog, type BulkExportDialogRef } from "/components/documents/BulkExportDialog";
import type { DocumentsPageUserEmailQuery } from "./__generated__/DocumentsPageUserEmailQuery.graphql";
const documentsFragment = graphql`
fragment DocumentsPageListFragment on Organization
@@ -81,6 +85,16 @@ type Props = {
queryRef: PreloadedQuery<DocumentGraphListQuery>;
};
const UserEmailQuery = graphql`
query DocumentsPageUserEmailQuery {
viewer {
user {
email
}
}
}
`;
export default function DocumentsPage(props: Props) {
const { __ } = useTranslate();
@@ -88,6 +102,9 @@ export default function DocumentsPage(props: Props) {
documentsQuery,
props.queryRef
).organization;
const userEmailData = useLazyLoadQuery<DocumentsPageUserEmailQuery>(UserEmailQuery, {});
const defaultEmail = userEmailData.viewer.user.email;
const pagination = usePaginationFragment(
documentsFragment,
organization as DocumentsPageListFragment$key
@@ -99,9 +116,10 @@ export default function DocumentsPage(props: Props) {
const connectionId = pagination.data.documents.__id;
const [sendSigningNotifications] = useSendSigningNotificationsMutation();
const [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation();
const [bulkExportDocuments] = useBulkExportDocumentsMutation();
const [bulkExportDocuments, isBulkExporting] = useBulkExportDocumentsMutation();
const { list: selection, toggle, clear, reset } = useList<string>([]);
const confirm = useConfirm();
const bulkExportDialogRef = useRef<BulkExportDialogRef>(null);
usePageTitle(__("Documents"));
@@ -136,11 +154,16 @@ export default function DocumentsPage(props: Props) {
);
};
const handleBulkExport = () => {
const handleBulkExport = (options: { withWatermark: boolean; withSignatures: boolean; watermarkEmail?: string }) => {
const input = {
documentIds: selection,
withWatermark: options.withWatermark,
withSignatures: options.withSignatures,
...(options.withWatermark && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
};
bulkExportDocuments({
variables: {
input: { documentIds: selection },
},
variables: { input },
}).then(() => {
clear();
});
@@ -216,14 +239,21 @@ export default function DocumentsPage(props: Props) {
{__("Request signature")}
</Button>
</SignatureDocumentsDialog>
<Button
variant="secondary"
icon={IconArrowDown}
onClick={handleBulkExport}
className="py-0.5 px-2 text-xs h-6 min-h-6"
<BulkExportDialog
ref={bulkExportDialogRef}
onExport={handleBulkExport}
isLoading={isBulkExporting}
defaultEmail={defaultEmail}
selectedCount={selection.length}
>
{__("Export")}
</Button>
<Button
variant="secondary"
icon={IconArrowDown}
className="py-0.5 px-2 text-xs h-6 min-h-6"
>
{__("Export")}
</Button>
</BulkExportDialog>
<Button
variant="danger"
icon={IconTrashCan}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<804ed692b3f8c67b2a73ca09260e5d25>>
* @generated SignedSource<<f860e097afe4111c629ecf78d873a84e>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -11,6 +11,9 @@
import { ConcreteRequest } from 'relay-runtime';
export type ExportDocumentVersionPDFInput = {
documentVersionId: string;
watermarkEmail?: string | null | undefined;
withSignatures: boolean;
withWatermark: boolean;
};
export type DocumentDetailPageExportPDFMutation$variables = {
input: ExportDocumentVersionPDFInput;

View File

@@ -0,0 +1,120 @@
/**
* @generated SignedSource<<c1a6e41382db3951401d70c039c49f01>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DocumentDetailPageUserEmailQuery$variables = Record<PropertyKey, never>;
export type DocumentDetailPageUserEmailQuery$data = {
readonly viewer: {
readonly user: {
readonly email: string;
};
};
};
export type DocumentDetailPageUserEmailQuery = {
response: DocumentDetailPageUserEmailQuery$data;
variables: DocumentDetailPageUserEmailQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
},
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "DocumentDetailPageUserEmailQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
(v0/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "DocumentDetailPageUserEmailQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
(v0/*: any*/),
(v1/*: any*/)
],
"storageKey": null
},
(v1/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "9321ba3f2fb159e06a07bfa9b2d91922",
"id": null,
"metadata": {},
"name": "DocumentDetailPageUserEmailQuery",
"operationKind": "query",
"text": "query DocumentDetailPageUserEmailQuery {\n viewer {\n user {\n email\n id\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "b4ffc243e94463feab8fd6555a674c58";
export default node;

View File

@@ -0,0 +1,120 @@
/**
* @generated SignedSource<<7ff59bf602dca8eafe0a671cb7e95aad>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DocumentsPageUserEmailQuery$variables = Record<PropertyKey, never>;
export type DocumentsPageUserEmailQuery$data = {
readonly viewer: {
readonly user: {
readonly email: string;
};
};
};
export type DocumentsPageUserEmailQuery = {
response: DocumentsPageUserEmailQuery$data;
variables: DocumentsPageUserEmailQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
},
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "DocumentsPageUserEmailQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
(v0/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "DocumentsPageUserEmailQuery",
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "user",
"plural": false,
"selections": [
(v0/*: any*/),
(v1/*: any*/)
],
"storageKey": null
},
(v1/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "d2d6cd91f55d59dc35d1c919f7760bac",
"id": null,
"metadata": {},
"name": "DocumentsPageUserEmailQuery",
"operationKind": "query",
"text": "query DocumentsPageUserEmailQuery {\n viewer {\n user {\n email\n id\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "e8af56c98fa637c3e7487374fb2eaa0e";
export default node;

View File

@@ -31,7 +31,10 @@ type (
ExportJobs []*ExportJob
DocumentExportArguments struct {
DocumentIDs []gid.GID `json:"document_ids"`
DocumentIDs []gid.GID `json:"document_ids"`
WithWatermark bool `json:"with_watermark"`
WatermarkEmail *string `json:"watermark_email"`
WithSignatures bool `json:"with_signatures"`
}
FrameworkExportArguments struct {

View File

@@ -60,7 +60,6 @@ func TestRenderHTML(t *testing.T) {
"<td>1</td>",
"PUBLIC",
"John Doe",
"Test document description",
"Alice Smith",
},
},
@@ -80,7 +79,6 @@ func TestRenderHTML(t *testing.T) {
wantContains: []string{
"Test &amp;amp; &amp;lt;Script&amp;gt; Title",
"John &amp;lt;script&amp;gt;alert(&amp;#39;xss&amp;#39;)&amp;lt;/script&amp;gt; Doe",
"Description with &amp;amp; symbols and &amp;lt;tags&amp;gt;",
"Alice &amp;amp; &amp;lt;Bob&amp;gt;",
},
wantNotContains: []string{

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/mail"
"net/url"
"os"
"regexp"
@@ -20,6 +21,7 @@ import (
"github.com/getprobo/probo/pkg/html2pdf"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/statelesstoken"
"github.com/getprobo/probo/pkg/watermarkpdf"
"github.com/jackc/pgx/v5"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
@@ -833,10 +835,20 @@ func (s *DocumentService) RequestExport(
documentIDs []gid.GID,
recipientEmail string,
recipientName string,
options BulkExportOptions,
) (*coredata.ExportJob, error) {
var exportJobID gid.GID
exportJob := &coredata.ExportJob{}
if options.WithWatermark {
if options.WatermarkEmail == nil {
return nil, fmt.Errorf("watermark email is required when with watermark is true")
}
if _, err := mail.ParseAddress(*options.WatermarkEmail); err != nil {
return nil, fmt.Errorf("invalid email address")
}
}
err := s.svc.pg.WithTx(ctx, func(conn pg.Conn) error {
for _, documentID := range documentIDs {
document := &coredata.Document{}
@@ -849,7 +861,10 @@ func (s *DocumentService) RequestExport(
exportJobID = gid.New(s.svc.scope.GetTenantID(), coredata.ExportJobEntityType)
args := coredata.DocumentExportArguments{
DocumentIDs: documentIDs,
DocumentIDs: documentIDs,
WithWatermark: options.WithWatermark,
WatermarkEmail: options.WatermarkEmail,
WithSignatures: options.WithSignatures,
}
argsJSON, err := json.Marshal(args)
if err != nil {
@@ -1159,16 +1174,29 @@ func (s *DocumentService) CancelSignatureRequest(
)
}
type ExportPDFOptions struct {
WithWatermark bool
WatermarkEmail *string
WithSignatures bool
}
type BulkExportOptions struct {
WithWatermark bool
WatermarkEmail *string
WithSignatures bool
}
func (s *DocumentService) ExportPDF(
ctx context.Context,
documentVersionID gid.GID,
options ExportPDFOptions,
) ([]byte, error) {
var data []byte
err := s.svc.pg.WithTx(
ctx,
func(conn pg.Conn) (err error) {
data, err = exportDocumentPDF(ctx, s.html2pdfConverter, conn, s.svc.scope, documentVersionID)
data, err = exportDocumentPDF(ctx, s.html2pdfConverter, conn, s.svc.scope, documentVersionID, options)
if err != nil {
return fmt.Errorf("cannot export document PDF: %w", err)
}
@@ -1206,7 +1234,18 @@ func (s *DocumentService) BuildAndUploadExport(ctx context.Context, exportJobID
defer tempFile.Close()
defer os.Remove(tempFile.Name())
err = s.Export(ctx, documentIDs, tempFile)
exportArgs, err := exportJob.GetDocumentExportArguments()
if err != nil {
return fmt.Errorf("cannot get export arguments: %w", err)
}
exportOptions := BulkExportOptions{
WithWatermark: exportArgs.WithWatermark,
WatermarkEmail: exportArgs.WatermarkEmail,
WithSignatures: exportArgs.WithSignatures,
}
err = s.Export(ctx, documentIDs, tempFile, exportOptions)
if err != nil {
return fmt.Errorf("cannot export documents: %w", err)
}
@@ -1281,6 +1320,7 @@ func exportDocumentPDF(
conn pg.Conn,
scope coredata.Scoper,
documentVersionID gid.GID,
options ExportPDFOptions,
) ([]byte, error) {
document := &coredata.Document{}
version := &coredata.DocumentVersion{}
@@ -1296,32 +1336,45 @@ func exportDocumentPDF(
return nil, fmt.Errorf("cannot load document: %w", err)
}
cursor := page.NewCursor(
100,
nil,
page.Head,
page.OrderBy[coredata.DocumentVersionSignatureOrderField]{
Field: coredata.DocumentVersionSignatureOrderFieldCreatedAt,
Direction: page.OrderDirectionAsc,
},
)
if err := signatures.LoadByDocumentVersionID(ctx, conn, scope, documentVersionID, cursor); err != nil {
return nil, fmt.Errorf("cannot load document version signatures: %w", err)
}
if err := owner.LoadByID(ctx, conn, scope, document.OwnerID); err != nil {
return nil, fmt.Errorf("cannot load document owner: %w", err)
}
// TODO: refactor this to use a single query
for _, sig := range signatures {
if _, ok := peopleMap[sig.SignedBy]; !ok {
people := &coredata.People{}
if err := people.LoadByID(ctx, conn, scope, sig.SignedBy); err != nil {
return nil, fmt.Errorf("cannot load people %q: %w", sig.SignedBy, err)
var signatureData []docgen.SignatureData
if options.WithSignatures {
cursor := page.NewCursor(
100,
nil,
page.Head,
page.OrderBy[coredata.DocumentVersionSignatureOrderField]{
Field: coredata.DocumentVersionSignatureOrderFieldCreatedAt,
Direction: page.OrderDirectionAsc,
},
)
if err := signatures.LoadByDocumentVersionID(ctx, conn, scope, documentVersionID, cursor); err != nil {
return nil, fmt.Errorf("cannot load document version signatures: %w", err)
}
// TODO: refactor this to use a single query
for _, sig := range signatures {
if _, ok := peopleMap[sig.SignedBy]; !ok {
people := &coredata.People{}
if err := people.LoadByID(ctx, conn, scope, sig.SignedBy); err != nil {
return nil, fmt.Errorf("cannot load people %q: %w", sig.SignedBy, err)
}
peopleMap[sig.SignedBy] = people
}
}
signatureData = make([]docgen.SignatureData, len(signatures))
for i, sig := range signatures {
signatureData[i] = docgen.SignatureData{
SignedBy: peopleMap[sig.SignedBy].FullName,
SignedAt: sig.SignedAt,
State: sig.State,
RequestedAt: sig.RequestedAt,
}
peopleMap[sig.SignedBy] = people
}
}
@@ -1340,16 +1393,7 @@ func exportDocumentPDF(
Classification: classification,
Approver: owner.FullName,
PublishedAt: version.PublishedAt,
Signatures: make([]docgen.SignatureData, len(signatures)),
}
for i, sig := range signatures {
docData.Signatures[i] = docgen.SignatureData{
SignedBy: peopleMap[sig.SignedBy].FullName,
SignedAt: sig.SignedAt,
State: sig.State,
RequestedAt: sig.RequestedAt,
}
Signatures: signatureData,
}
htmlContent, err := docgen.RenderHTML(docData)
@@ -1377,6 +1421,22 @@ func exportDocumentPDF(
if err != nil {
return nil, fmt.Errorf("cannot read PDF data: %w", err)
}
if options.WithWatermark {
if options.WatermarkEmail == nil {
return nil, fmt.Errorf("watermark email is required with watermark enabled")
}
if _, err := mail.ParseAddress(*options.WatermarkEmail); err != nil {
return nil, fmt.Errorf("invalid email address")
}
watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail)
if err != nil {
return nil, fmt.Errorf("cannot add watermark to PDF: %w", err)
}
return watermarkedPDF, nil
}
return pdfData, nil
}
@@ -1384,6 +1444,7 @@ func (s *DocumentService) Export(
ctx context.Context,
documentIDs []gid.GID,
file io.Writer,
options BulkExportOptions,
) (err error) {
archive := zip.NewWriter(file)
defer func() {
@@ -1406,12 +1467,19 @@ func (s *DocumentService) Export(
return fmt.Errorf("cannot load document version for %q: %w", documentID, err)
}
pdfOptions := ExportPDFOptions{
WithWatermark: options.WithWatermark,
WatermarkEmail: options.WatermarkEmail,
WithSignatures: options.WithSignatures,
}
exportedPDF, err := exportDocumentPDF(
ctx,
s.html2pdfConverter,
conn,
s.svc.scope,
documentVersion.ID,
pdfOptions,
)
if err != nil {
return fmt.Errorf("cannot export document PDF for %q: %w", documentID, err)

View File

@@ -284,6 +284,7 @@ func (s FrameworkService) Export(
conn,
s.svc.scope,
documentVersion.ID,
ExportPDFOptions{WithSignatures: true},
)
if err != nil {
return fmt.Errorf("cannot export document PDF: %w", err)

View File

@@ -3303,6 +3303,9 @@ input UpdateDocumentInput {
input ExportDocumentVersionPDFInput {
documentVersionId: ID!
withWatermark: Boolean!
watermarkEmail: String
withSignatures: Boolean!
}
input DeleteDocumentInput {
@@ -4002,6 +4005,9 @@ input BulkDeleteDocumentsInput {
input BulkExportDocumentsInput {
documentIds: [ID!]!
withWatermark: Boolean!
watermarkEmail: String
withSignatures: Boolean!
}
type BulkDeleteDocumentsPayload {

View File

@@ -11713,6 +11713,9 @@ input UpdateDocumentInput {
input ExportDocumentVersionPDFInput {
documentVersionId: ID!
withWatermark: Boolean!
watermarkEmail: String
withSignatures: Boolean!
}
input DeleteDocumentInput {
@@ -12412,6 +12415,9 @@ input BulkDeleteDocumentsInput {
input BulkExportDocumentsInput {
documentIds: [ID!]!
withWatermark: Boolean!
watermarkEmail: String
withSignatures: Boolean!
}
type BulkDeleteDocumentsPayload {
@@ -63447,7 +63453,7 @@ func (ec *executionContext) unmarshalInputBulkExportDocumentsInput(ctx context.C
asMap[k] = v
}
fieldsInOrder := [...]string{"documentIds"}
fieldsInOrder := [...]string{"documentIds", "withWatermark", "watermarkEmail", "withSignatures"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
@@ -63461,6 +63467,27 @@ func (ec *executionContext) unmarshalInputBulkExportDocumentsInput(ctx context.C
return it, err
}
it.DocumentIds = data
case "withWatermark":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("withWatermark"))
data, err := ec.unmarshalNBoolean2bool(ctx, v)
if err != nil {
return it, err
}
it.WithWatermark = data
case "watermarkEmail":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("watermarkEmail"))
data, err := ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
it.WatermarkEmail = data
case "withSignatures":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("withSignatures"))
data, err := ec.unmarshalNBoolean2bool(ctx, v)
if err != nil {
return it, err
}
it.WithSignatures = data
}
}
@@ -66867,7 +66894,7 @@ func (ec *executionContext) unmarshalInputExportDocumentVersionPDFInput(ctx cont
asMap[k] = v
}
fieldsInOrder := [...]string{"documentVersionId"}
fieldsInOrder := [...]string{"documentVersionId", "withWatermark", "watermarkEmail", "withSignatures"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
@@ -66881,6 +66908,27 @@ func (ec *executionContext) unmarshalInputExportDocumentVersionPDFInput(ctx cont
return it, err
}
it.DocumentVersionID = data
case "withWatermark":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("withWatermark"))
data, err := ec.unmarshalNBoolean2bool(ctx, v)
if err != nil {
return it, err
}
it.WithWatermark = data
case "watermarkEmail":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("watermarkEmail"))
data, err := ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
it.WatermarkEmail = data
case "withSignatures":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("withSignatures"))
data, err := ec.unmarshalNBoolean2bool(ctx, v)
if err != nil {
return it, err
}
it.WithSignatures = data
}
}

View File

@@ -94,7 +94,10 @@ type BulkDeleteDocumentsPayload struct {
}
type BulkExportDocumentsInput struct {
DocumentIds []gid.GID `json:"documentIds"`
DocumentIds []gid.GID `json:"documentIds"`
WithWatermark bool `json:"withWatermark"`
WatermarkEmail *string `json:"watermarkEmail,omitempty"`
WithSignatures bool `json:"withSignatures"`
}
type BulkExportDocumentsPayload struct {
@@ -1053,6 +1056,9 @@ type EvidenceEdge struct {
type ExportDocumentVersionPDFInput struct {
DocumentVersionID gid.GID `json:"documentVersionId"`
WithWatermark bool `json:"withWatermark"`
WatermarkEmail *string `json:"watermarkEmail,omitempty"`
WithSignatures bool `json:"withSignatures"`
}
type ExportDocumentVersionPDFPayload struct {

View File

@@ -2628,7 +2628,13 @@ func (r *mutationResolver) BulkExportDocuments(ctx context.Context, input types.
panic(fmt.Errorf("user not found"))
}
documentExport, err := prb.Documents.RequestExport(ctx, input.DocumentIds, user.EmailAddress, user.FullName)
options := probo.BulkExportOptions{
WithWatermark: input.WithWatermark,
WithSignatures: input.WithSignatures,
WatermarkEmail: input.WatermarkEmail,
}
documentExport, err := prb.Documents.RequestExport(ctx, input.DocumentIds, user.EmailAddress, user.FullName, options)
if err != nil {
panic(fmt.Errorf("cannot request document export: %w", err))
}
@@ -2775,7 +2781,13 @@ func (r *mutationResolver) CancelSignatureRequest(ctx context.Context, input typ
func (r *mutationResolver) ExportDocumentVersionPDF(ctx context.Context, input types.ExportDocumentVersionPDFInput) (*types.ExportDocumentVersionPDFPayload, error) {
prb := r.ProboService(ctx, input.DocumentVersionID.TenantID())
pdf, err := prb.Documents.ExportPDF(ctx, input.DocumentVersionID)
options := probo.ExportPDFOptions{
WithSignatures: input.WithSignatures,
WithWatermark: input.WithWatermark,
WatermarkEmail: input.WatermarkEmail,
}
pdf, err := prb.Documents.ExportPDF(ctx, input.DocumentVersionID, options)
if err != nil {
panic(fmt.Errorf("cannot export document version PDF: %w", err))
}