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) {