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

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