Add background PDF generation for published document versions

Move PDF generation from synchronous publish flow to a background polling
job. Published versions with file_id IS NULL are picked up by the job,
which generates the PDF, uploads to S3, and links the file. Export PDF
now serves stored files for published versions (with optional signature
page and watermark) and generates on the fly for drafts.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-17 08:15:57 +02:00
parent ff175e3d0d
commit 25c590ffe6
13 changed files with 795 additions and 70 deletions

View File

@@ -1483,3 +1483,100 @@ func TestDocumentVersion_DeleteDraft(t *testing.T) {
},
)
}
func TestDocumentVersion_ExportPDFSignatures(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run(
"can export draft with signatures",
func(t *testing.T) {
t.Parallel()
_, docVersionID := createTestDocument(t, owner)
var result struct {
ExportDocumentVersionPDF struct {
Data string `json:"data"`
} `json:"exportDocumentVersionPDF"`
}
err := owner.Execute(`
mutation ExportPDF($input: ExportDocumentVersionPDFInput!) {
exportDocumentVersionPDF(input: $input) {
data
}
}
`, map[string]any{
"input": map[string]any{
"documentVersionId": docVersionID,
"withWatermark": false,
"withSignatures": true,
},
}, &result)
require.NoError(t, err)
assert.NotEmpty(t, result.ExportDocumentVersionPDF.Data)
},
)
t.Run(
"can export published with signatures",
func(t *testing.T) {
t.Parallel()
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
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(`
query GetVersions($id: ID!) {
node(id: $id) {
... on Document {
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges { node { id } }
}
}
}
}
`, map[string]any{"id": docID}, &versionResult)
require.NoError(t, err)
require.NotEmpty(t, versionResult.Node.Versions.Edges)
publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID
var result struct {
ExportDocumentVersionPDF struct {
Data string `json:"data"`
} `json:"exportDocumentVersionPDF"`
}
err = owner.Execute(`
mutation ExportPDF($input: ExportDocumentVersionPDFInput!) {
exportDocumentVersionPDF(input: $input) {
data
}
}
`, map[string]any{
"input": map[string]any{
"documentVersionId": publishedVersionID,
"withWatermark": false,
"withSignatures": true,
},
}, &result)
require.NoError(t, err)
assert.NotEmpty(t, result.ExportDocumentVersionPDF.Data)
},
)
}

View File

@@ -30,21 +30,23 @@ import (
type (
DocumentVersion struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
DocumentID gid.GID `db:"document_id"`
Title string `db:"title"`
Major int `db:"major"`
Minor int `db:"minor"`
Classification DocumentClassification `db:"classification"`
DocumentType DocumentType `db:"document_type"`
Content string `db:"content"`
Changelog string `db:"changelog"`
Status DocumentVersionStatus `db:"status"`
Orientation DocumentVersionOrientation `db:"orientation"`
PublishedAt *time.Time `db:"published_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
DocumentID gid.GID `db:"document_id"`
Title string `db:"title"`
Major int `db:"major"`
Minor int `db:"minor"`
Classification DocumentClassification `db:"classification"`
DocumentType DocumentType `db:"document_type"`
Content string `db:"content"`
Changelog string `db:"changelog"`
Status DocumentVersionStatus `db:"status"`
Orientation DocumentVersionOrientation `db:"orientation"`
FileID *gid.GID `db:"file_id"`
PdfAttemptCount int `db:"pdf_attempt_count"`
PublishedAt *time.Time `db:"published_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
DocumentVersions []*DocumentVersion
@@ -95,6 +97,8 @@ SELECT
changelog,
status,
orientation,
file_id,
pdf_attempt_count,
published_at,
created_at,
updated_at
@@ -159,6 +163,8 @@ SELECT
changelog,
status,
orientation,
file_id,
pdf_attempt_count,
published_at,
created_at,
updated_at
@@ -215,6 +221,8 @@ INSERT INTO document_versions (
changelog,
status,
orientation,
file_id,
pdf_attempt_count,
published_at,
created_at,
updated_at
@@ -233,28 +241,32 @@ VALUES (
@changelog,
@status,
@orientation,
@file_id,
@pdf_attempt_count,
@published_at,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": dv.ID,
"organization_id": dv.OrganizationID,
"document_id": dv.DocumentID,
"title": dv.Title,
"major": dv.Major,
"minor": dv.Minor,
"classification": dv.Classification,
"document_type": dv.DocumentType,
"content": dv.Content,
"changelog": dv.Changelog,
"status": dv.Status,
"orientation": dv.Orientation,
"published_at": dv.PublishedAt,
"created_at": dv.CreatedAt,
"updated_at": dv.UpdatedAt,
"tenant_id": scope.GetTenantID(),
"id": dv.ID,
"organization_id": dv.OrganizationID,
"document_id": dv.DocumentID,
"title": dv.Title,
"major": dv.Major,
"minor": dv.Minor,
"classification": dv.Classification,
"document_type": dv.DocumentType,
"content": dv.Content,
"changelog": dv.Changelog,
"status": dv.Status,
"orientation": dv.Orientation,
"file_id": dv.FileID,
"pdf_attempt_count": dv.PdfAttemptCount,
"published_at": dv.PublishedAt,
"created_at": dv.CreatedAt,
"updated_at": dv.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -295,6 +307,8 @@ SELECT
changelog,
status,
orientation,
file_id,
pdf_attempt_count,
published_at,
created_at,
updated_at
@@ -355,6 +369,8 @@ SELECT
changelog,
status,
orientation,
file_id,
pdf_attempt_count,
published_at,
created_at,
updated_at
@@ -411,6 +427,8 @@ SELECT
changelog,
status,
orientation,
file_id,
pdf_attempt_count,
published_at,
created_at,
updated_at
@@ -466,6 +484,8 @@ UPDATE document_versions SET
classification = @classification,
document_type = @document_type,
orientation = @orientation,
file_id = @file_id,
pdf_attempt_count = @pdf_attempt_count,
updated_at = @updated_at
WHERE %s
AND id = @document_version_id
@@ -485,6 +505,8 @@ WHERE %s
"classification": dv.Classification,
"document_type": dv.DocumentType,
"orientation": dv.Orientation,
"file_id": dv.FileID,
"pdf_attempt_count": dv.PdfAttemptCount,
"updated_at": dv.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
@@ -523,6 +545,87 @@ WHERE %s
return nil
}
func (dv *DocumentVersion) ClaimNextPublishedWithoutFileForUpdate(
ctx context.Context,
conn pg.Tx,
maxAttempts int,
) error {
q := `
SELECT
dv.id,
dv.organization_id,
dv.document_id,
dv.title,
dv.major,
dv.minor,
dv.classification,
dv.document_type,
dv.content,
dv.changelog,
dv.status,
dv.orientation,
dv.file_id,
dv.pdf_attempt_count,
dv.published_at,
dv.created_at,
dv.updated_at
FROM
document_versions dv
INNER JOIN
documents d ON d.id = dv.document_id AND d.tenant_id = dv.tenant_id
WHERE
dv.status = 'PUBLISHED'
AND dv.file_id IS NULL
AND dv.pdf_attempt_count < @max_pdf_attempts
AND d.deleted_at IS NULL
ORDER BY dv.created_at ASC
LIMIT 1
FOR UPDATE OF dv SKIP LOCKED;
`
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{
"max_pdf_attempts": maxAttempts,
})
if err != nil {
return fmt.Errorf("cannot query document versions: %w", err)
}
result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNoDocumentPDFJobAvailable
}
return fmt.Errorf("cannot collect document version: %w", err)
}
now := time.Now()
result.PdfAttemptCount++
result.UpdatedAt = now
uq := `
UPDATE document_versions SET
pdf_attempt_count = @pdf_attempt_count,
updated_at = @updated_at
WHERE
tenant_id = @tenant_id
AND id = @id
`
uargs := pgx.StrictNamedArgs{
"id": result.ID,
"tenant_id": result.ID.TenantID(),
"pdf_attempt_count": result.PdfAttemptCount,
"updated_at": result.UpdatedAt,
}
if _, err := conn.Exec(ctx, uq, uargs); err != nil {
return fmt.Errorf("cannot mark document version as generating PDF: %w", err)
}
*dv = result
return nil
}
func (dv *DocumentVersions) CountByDocumentID(
ctx context.Context,
conn pg.Querier,

View File

@@ -19,7 +19,8 @@ import (
)
var (
ErrResourceNotFound = errors.New("resource not found")
ErrResourceAlreadyExists = errors.New("resource already exists")
ErrResourceInUse = errors.New("resource is in use")
ErrResourceNotFound = errors.New("resource not found")
ErrResourceAlreadyExists = errors.New("resource already exists")
ErrResourceInUse = errors.New("resource is in use")
ErrNoDocumentPDFJobAvailable = errors.New("no document PDF job available")
)

View File

@@ -0,0 +1,20 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.inc>
--
-- Permission to use, copy, modify, and 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.
ALTER TABLE document_versions
ADD COLUMN file_id TEXT REFERENCES files(id),
ADD COLUMN pdf_attempt_count INT NOT NULL DEFAULT 0;
ALTER TABLE document_versions
ALTER COLUMN pdf_attempt_count DROP DEFAULT;

View File

@@ -44,6 +44,9 @@ var (
//go:embed transfer_impact_assessments_template.html
transferImpactAssessmentsTemplateContent string
//go:embed signature_page_template.html
signaturePageTemplateContent string
templateFuncs = template.FuncMap{
"now": func() time.Time { return time.Now() },
"eq": func(a, b any) bool { return a == b },
@@ -183,6 +186,8 @@ var (
dataProtectionImpactAssessmentsTemplate = template.Must(template.New("dataProtectionImpactAssessments").Funcs(templateFuncs).Parse(dataProtectionImpactAssessmentsTemplateContent))
transferImpactAssessmentsTemplate = template.Must(template.New("transferImpactAssessments").Funcs(templateFuncs).Parse(transferImpactAssessmentsTemplateContent))
signaturePageTemplate = template.Must(template.New("signaturePage").Funcs(templateFuncs).Parse(signaturePageTemplateContent))
)
type (
@@ -210,6 +215,11 @@ type (
RequestedAt time.Time
}
SignaturePageData struct {
Signatures []SignatureData
Landscape bool
}
ProcessingActivityTableData struct {
CompanyName string
CompanyHorizontalLogoBase64 string
@@ -399,6 +409,15 @@ func RenderHTML(data DocumentData) ([]byte, error) {
return buf.Bytes(), nil
}
func RenderSignaturePageHTML(data SignaturePageData) ([]byte, error) {
var buf bytes.Buffer
if err := signaturePageTemplate.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("cannot execute signature page template: %w", err)
}
return buf.Bytes(), nil
}
func RenderProcessingActivitiesTableHTML(data ProcessingActivityTableData) ([]byte, error) {
var buf bytes.Buffer
if err := processingActivitiesTemplate.Execute(&buf, data); err != nil {

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
@page {
size: {{- if .Landscape}} A4 landscape {{- else}} A4 {{- end}};
margin: 1in;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 10pt;
line-height: 1.5;
color: #1a1a1a;
margin: 0;
padding: 0;
}
.signatures-title {
font-size: 13pt;
font-weight: bold;
color: #000;
margin-bottom: 15px;
}
.signatures-table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
margin-top: 10px;
}
.signatures-table th,
.signatures-table td {
padding: 6px 8px;
text-align: left;
border: 1px solid #ddd;
}
.signatures-table th {
background: #f5f5f5;
font-weight: bold;
color: #333;
}
.signature-signed {
color: #000;
font-weight: bold;
}
.signature-requested {
color: #666;
font-style: italic;
}
</style>
</head>
<body>
<h2 class="signatures-title">Document Signatures</h2>
<table class="signatures-table">
<thead>
<tr>
<th>Signatory</th>
<th>Status</th>
<th>Requested Date</th>
<th>Signed Date</th>
</tr>
</thead>
<tbody>
{{- range .Signatures}}
<tr>
<td>{{.SignedBy}}</td>
<td>
{{- if eq (string .State) "SIGNED"}}
<span class="signature-signed">{{string .State}}</span>
{{- else}}
<span class="signature-requested">{{string .State}}</span>
{{- end}}
</td>
<td>{{.RequestedAt.Format "Jan 2, 2006"}}</td>
<td>
{{- if .SignedAt}}
{{.SignedAt.Format "Jan 2, 2006"}}
{{- else}}
-
{{- end}}
</td>
</tr>
{{- end}}
</tbody>
</table>
</body>
</html>

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package watermarkpdf
package pdfutils
import (
"bytes"
@@ -20,6 +20,7 @@ import (
"image"
"image/color"
"image/png"
"io"
"math"
"time"
@@ -47,6 +48,20 @@ var (
fontColor = color.RGBA{0, 0, 0, 255}
)
func MergePDFs(pdfs ...[]byte) ([]byte, error) {
readers := make([]io.ReadSeeker, len(pdfs))
for i, pdf := range pdfs {
readers[i] = bytes.NewReader(pdf)
}
var buf bytes.Buffer
if err := api.MergeRaw(readers, &buf, false, nil); err != nil {
return nil, fmt.Errorf("cannot merge PDFs: %w", err)
}
return buf.Bytes(), nil
}
func AddConfidentialWithTimestamp(pdfData []byte, email mail.Addr) ([]byte, error) {
reader := bytes.NewReader(pdfData)

View File

@@ -0,0 +1,85 @@
// 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 probo
import (
"context"
"errors"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
"go.probo.inc/probo/pkg/coredata"
)
const maxPDFAttempts = 3
type documentPDFHandler struct {
service *Service
logger *log.Logger
}
func NewDocumentPDFWorker(
service *Service,
logger *log.Logger,
opts ...worker.Option,
) *worker.Worker[coredata.DocumentVersion] {
h := &documentPDFHandler{
service: service,
logger: logger,
}
return worker.New(
"document-pdf-worker",
h,
logger,
opts...,
)
}
func (h *documentPDFHandler) Claim(ctx context.Context) (coredata.DocumentVersion, error) {
var version coredata.DocumentVersion
if err := h.service.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
return version.ClaimNextPublishedWithoutFileForUpdate(ctx, tx, maxPDFAttempts)
},
); err != nil {
if errors.Is(err, coredata.ErrNoDocumentPDFJobAvailable) {
return coredata.DocumentVersion{}, worker.ErrNoTask
}
return coredata.DocumentVersion{}, err
}
return version, nil
}
func (h *documentPDFHandler) Process(ctx context.Context, version coredata.DocumentVersion) error {
tenantService := h.service.WithTenant(version.ID.TenantID())
if err := tenantService.Documents.generateAndUploadPublicationPDF(ctx, &version); err != nil {
h.logger.ErrorCtx(
ctx,
"document pdf worker failure",
log.Error(err),
log.String("document_version_id", version.ID.String()),
log.Int("attempt", version.PdfAttemptCount),
)
return err
}
return nil
}

View File

@@ -16,6 +16,7 @@ package probo
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
@@ -40,10 +41,10 @@ import (
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/pdfutils"
"go.probo.inc/probo/pkg/prosemirror"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/validator"
"go.probo.inc/probo/pkg/watermarkpdf"
)
type (
@@ -2149,14 +2150,181 @@ func exportDocumentPDF(
documentVersionID gid.GID,
options ExportPDFOptions,
) ([]byte, error) {
document := &coredata.Document{}
version := &coredata.DocumentVersion{}
organization := &coredata.Organization{}
if err := version.LoadByID(ctx, conn, scope, documentVersionID); err != nil {
return nil, fmt.Errorf("cannot load document version: %w", err)
}
// Published versions with a stored PDF: use the stored file,
// append signature page and watermark as needed.
if version.FileID != nil {
return exportStoredPDF(ctx, svc, html2pdfConverter, conn, scope, version, options)
}
// No stored PDF: generate on the fly without watermark — watermark is
// applied after merging the signature page so all pages are watermarked.
generateOptions := options
generateOptions.WithWatermark = false
generateOptions.WatermarkEmail = nil
pdfData, err := generateDocumentPDF(ctx, svc, html2pdfConverter, conn, scope, version, generateOptions)
if err != nil {
return nil, err
}
if options.WithSignatures {
signaturePagePDF, err := generateSignaturePagePDF(ctx, svc, html2pdfConverter, conn, scope, version)
if err != nil {
return nil, fmt.Errorf("cannot generate signature page: %w", err)
}
if signaturePagePDF != nil {
pdfData, err = pdfutils.MergePDFs(pdfData, signaturePagePDF)
if err != nil {
return nil, fmt.Errorf("cannot merge signature page: %w", err)
}
}
}
if options.WithWatermark {
if options.WatermarkEmail == nil {
return nil, fmt.Errorf("watermark email is required with watermark enabled")
}
pdfData, err = pdfutils.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail)
if err != nil {
return nil, fmt.Errorf("cannot add watermark to PDF: %w", err)
}
}
return pdfData, nil
}
func exportStoredPDF(
ctx context.Context,
svc *TenantService,
html2pdfConverter *html2pdf.Converter,
conn pg.Querier,
scope coredata.Scoper,
version *coredata.DocumentVersion,
options ExportPDFOptions,
) ([]byte, error) {
fileRecord := &coredata.File{}
if err := fileRecord.LoadByID(ctx, conn, scope, *version.FileID); err != nil {
return nil, fmt.Errorf("cannot load document version file: %w", err)
}
pdfData, err := svc.fileManager.GetFileBytes(ctx, fileRecord)
if err != nil {
return nil, fmt.Errorf("cannot download document version PDF: %w", err)
}
if options.WithSignatures {
signaturePagePDF, err := generateSignaturePagePDF(ctx, svc, html2pdfConverter, conn, scope, version)
if err != nil {
return nil, fmt.Errorf("cannot generate signature page: %w", err)
}
if signaturePagePDF != nil {
pdfData, err = pdfutils.MergePDFs(pdfData, signaturePagePDF)
if err != nil {
return nil, fmt.Errorf("cannot merge signature page: %w", err)
}
}
}
if options.WithWatermark {
if options.WatermarkEmail == nil {
return nil, fmt.Errorf("watermark email is required with watermark enabled")
}
pdfData, err = pdfutils.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail)
if err != nil {
return nil, fmt.Errorf("cannot add watermark to PDF: %w", err)
}
}
return pdfData, nil
}
func generateSignaturePagePDF(
ctx context.Context,
svc *TenantService,
html2pdfConverter *html2pdf.Converter,
conn pg.Querier,
scope coredata.Scoper,
version *coredata.DocumentVersion,
) ([]byte, error) {
signaturesWithPeople := &coredata.DocumentVersionSignaturesWithPeople{}
if err := signaturesWithPeople.LoadByDocumentVersionIDWithPeople(ctx, conn, scope, version.ID, 1_000); err != nil {
return nil, fmt.Errorf("cannot load document version signatures: %w", err)
}
if len(*signaturesWithPeople) == 0 {
return nil, nil
}
signatureData := make([]docgen.SignatureData, len(*signaturesWithPeople))
for i, sig := range *signaturesWithPeople {
signatureData[i] = docgen.SignatureData{
SignedBy: sig.SignedByFullName,
SignedAt: sig.SignedAt,
State: sig.State,
RequestedAt: sig.RequestedAt,
}
}
isLandscape := version.Orientation == coredata.DocumentVersionOrientationLandscape
htmlContent, err := docgen.RenderSignaturePageHTML(docgen.SignaturePageData{
Signatures: signatureData,
Landscape: isLandscape,
})
if err != nil {
return nil, fmt.Errorf("cannot render signature page HTML: %w", err)
}
orientation := html2pdf.OrientationPortrait
if isLandscape {
orientation = html2pdf.OrientationLandscape
}
cfg := html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: orientation,
MarginTop: html2pdf.NewMarginInches(1.0),
MarginBottom: html2pdf.NewMarginInches(1.0),
MarginLeft: html2pdf.NewMarginInches(1.0),
MarginRight: html2pdf.NewMarginInches(1.0),
PrintBackground: true,
Scale: 1.0,
}
pdfReader, err := html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg)
if err != nil {
return nil, fmt.Errorf("cannot generate signature page PDF: %w", err)
}
pdfData, err := io.ReadAll(pdfReader)
if err != nil {
return nil, fmt.Errorf("cannot read signature page PDF: %w", err)
}
return pdfData, nil
}
func generateDocumentPDF(
ctx context.Context,
svc *TenantService,
html2pdfConverter *html2pdf.Converter,
conn pg.Querier,
scope coredata.Scoper,
version *coredata.DocumentVersion,
options ExportPDFOptions,
) ([]byte, error) {
document := &coredata.Document{}
organization := &coredata.Organization{}
if err := document.LoadByID(ctx, conn, scope, version.DocumentID); err != nil {
return nil, fmt.Errorf("cannot load document: %w", err)
}
@@ -2165,7 +2333,7 @@ func exportDocumentPDF(
var approverNames []string
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, scope, version.ID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return nil, fmt.Errorf("cannot load last approval quorum: %w", err)
}
@@ -2215,24 +2383,6 @@ func exportDocumentPDF(
return nil, fmt.Errorf("cannot load organization: %w", err)
}
var signatureData []docgen.SignatureData
if options.WithSignatures {
signaturesWithPeople := &coredata.DocumentVersionSignaturesWithPeople{}
if err := signaturesWithPeople.LoadByDocumentVersionIDWithPeople(ctx, conn, scope, documentVersionID, 1_000); err != nil {
return nil, fmt.Errorf("cannot load document version signatures: %w", err)
}
signatureData = make([]docgen.SignatureData, len(*signaturesWithPeople))
for i, sig := range *signaturesWithPeople {
signatureData[i] = docgen.SignatureData{
SignedBy: sig.SignedByFullName,
SignedAt: sig.SignedAt,
State: sig.State,
RequestedAt: sig.RequestedAt,
}
}
}
classification := docgen.ClassificationSecret
switch version.Classification {
case coredata.DocumentClassificationPublic:
@@ -2265,7 +2415,6 @@ func exportDocumentPDF(
Classification: classification,
Approvers: approverNames,
PublishedAt: version.PublishedAt,
Signatures: signatureData,
CompanyHorizontalLogoBase64: horizontalLogoBase64,
Landscape: isLandscape,
}
@@ -2307,7 +2456,7 @@ func exportDocumentPDF(
return nil, fmt.Errorf("watermark email is required with watermark enabled")
}
watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail)
watermarkedPDF, err := pdfutils.AddConfidentialWithTimestamp(pdfData, *options.WatermarkEmail)
if err != nil {
return nil, fmt.Errorf("cannot add watermark to PDF: %w", err)
}
@@ -2599,3 +2748,81 @@ func (s *DocumentService) publishMinorVersionInTx(
return document, documentVersion, nil
}
func (s *DocumentService) generateAndUploadPublicationPDF(
ctx context.Context,
documentVersion *coredata.DocumentVersion,
) error {
var pdfData []byte
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
pdfData, err = exportDocumentPDF(
ctx,
s.svc,
s.html2pdfConverter,
conn,
s.svc.scope,
documentVersion.ID,
ExportPDFOptions{},
)
return err
},
)
if err != nil {
return fmt.Errorf("cannot generate publication PDF: %w", err)
}
now := time.Now()
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("%s v%d.%d.pdf", documentVersion.Title, documentVersion.Major, documentVersion.Minor),
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": "document-version-pdf",
"document-version-id": documentVersion.ID.String(),
},
)
if err != nil {
return fmt.Errorf("cannot upload publication PDF: %w", err)
}
fileRecord.FileSize = fileSize
err = s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := fileRecord.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert file record: %w", err)
}
documentVersion.FileID = &fileRecord.ID
documentVersion.UpdatedAt = now
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document version with file ID: %w", err)
}
return nil
},
)
if err != nil {
return fmt.Errorf("cannot save publication PDF file record: %w", err)
}
return nil
}

View File

@@ -681,6 +681,20 @@ func (impl *Implm) Run(
},
)
documentPDFWorker := probo.NewDocumentPDFWorker(
proboService,
l.Named("document-pdf-worker"),
worker.WithInterval(30*time.Second),
)
documentPDFWorkerCtx, stopDocumentPDFWorker := context.WithCancel(context.Background())
wg.Go(
func() {
if err := documentPDFWorker.Run(documentPDFWorkerCtx); err != nil {
cancel(fmt.Errorf("document pdf worker crashed: %w", err))
}
},
)
accessReviewWorkerCtx, stopAccessReviewWorker := context.WithCancel(context.Background())
wg.Go(
func() {
@@ -774,6 +788,7 @@ func (impl *Implm) Run(
stopESignService()
stopMailingListWorker()
stopEvidenceDescriptionWorker()
stopDocumentPDFWorker()
stopExportJobExporter()
stopAccessReviewWorker()
stopIAMService()

View File

@@ -17,11 +17,10 @@ package trust
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"errors"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
@@ -29,7 +28,7 @@ import (
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/watermarkpdf"
"go.probo.inc/probo/pkg/pdfutils"
)
type (
@@ -82,7 +81,7 @@ func (s *DocumentService) ExportPDF(
return nil, fmt.Errorf("cannot export document PDF: %w", err)
}
watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, email)
watermarkedPDF, err := pdfutils.AddConfidentialWithTimestamp(pdfData, email)
if err != nil {
return nil, fmt.Errorf("cannot add watermark to PDF: %w", err)
}
@@ -141,8 +140,7 @@ func (s *DocumentService) exportPDFData(
) ([]byte, error) {
document := &coredata.Document{}
version := &coredata.DocumentVersion{}
organization := &coredata.Organization{}
var approverNames []string
fileRecord := &coredata.File{}
err := s.svc.pg.WithConn(
ctx,
@@ -163,6 +161,54 @@ func (s *DocumentService) exportPDFData(
return fmt.Errorf("cannot load latest published document version: %w", err)
}
if version.FileID == nil {
return nil
}
if err := fileRecord.LoadByID(ctx, conn, s.svc.scope, *version.FileID); err != nil {
return fmt.Errorf("cannot load document version file: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
if version.FileID != nil {
pdfData, err := s.svc.fileManager.GetFileBytes(ctx, fileRecord)
if err != nil {
return nil, fmt.Errorf("cannot fetch document PDF file: %w", err)
}
return pdfData, nil
}
// TODO: remove on-the-fly fallback once all published versions have a stored PDF.
pdfData, err := s.generatePDFOnTheFly(ctx, document, version)
if err != nil {
return nil, fmt.Errorf("cannot generate PDF on the fly: %w", err)
}
return pdfData, nil
}
// generatePDFOnTheFly generates a PDF from scratch for versions that don't have
// a stored file yet. Can be removed once all published versions have been
// processed by the document PDF worker.
func (s *DocumentService) generatePDFOnTheFly(
ctx context.Context,
document *coredata.Document,
version *coredata.DocumentVersion,
) ([]byte, error) {
organization := &coredata.Organization{}
var approverNames []string
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, version.ID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {

View File

@@ -25,7 +25,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/watermarkpdf"
"go.probo.inc/probo/pkg/pdfutils"
)
type ReportService struct {
@@ -112,7 +112,7 @@ func (s ReportService) ExportPDF(
return nil, fmt.Errorf("cannot export report PDF: %w", err)
}
watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(pdfData, email)
watermarkedPDF, err := pdfutils.AddConfidentialWithTimestamp(pdfData, email)
if err != nil {
return nil, fmt.Errorf("cannot add watermark to PDF: %w", err)
}

View File

@@ -25,7 +25,7 @@ import (
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/watermarkpdf"
"go.probo.inc/probo/pkg/pdfutils"
)
type TrustCenterFileService struct {
@@ -104,7 +104,7 @@ func (s *TrustCenterFileService) ExportFile(
}
if mimeType == "application/pdf" {
watermarkedPDF, err := watermarkpdf.AddConfidentialWithTimestamp(fileData, email)
watermarkedPDF, err := pdfutils.AddConfidentialWithTimestamp(fileData, email)
if err != nil {
return nil, "", fmt.Errorf("cannot add watermark to PDF: %w", err)
}