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

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