Redesign document approval flow

Replace the per-approver add/remove model with a quorum-based approval
system. Documents now have default approvers that are pre-populated when
requesting approval, and the publish dialog lets users adjust the list
before submitting.

Key changes:

- Add PENDING_APPROVAL document version status with dedicated transitions
- Introduce approval quorums with request/approve/reject/void lifecycle
- Add default approvers per document (stored in document_default_approvers)
  with MERGE-based upsert for efficient sync
- Add NoDuplicates validator for slice fields
- Split ALTER TYPE ADD VALUE migrations into separate files (required by
  PostgreSQL when run inside transactions)
- Use VOIDED consistently for both quorum status and decision state enums
- Expose void/approve/reject through GraphQL and MCP, with e2e tests
- Add approval management UI: publish dialog with approver selection,
  approval list with void support, and external approve/reject page

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-09 11:17:51 +02:00
parent 17f579b8a2
commit ab5f42ad74
38 changed files with 2125 additions and 922 deletions

View File

@@ -47,6 +47,36 @@ func Required() ValidatorFunc {
}
}
// NoDuplicates validates that a slice contains no duplicate elements.
func NoDuplicates() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
rv := reflect.ValueOf(actualValue)
if rv.Kind() != reflect.Slice {
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice")
}
if !rv.Type().Elem().Comparable() {
return newValidationError(ErrorCodeInvalidFormat, "slice elements must be comparable")
}
seen := make(map[any]struct{}, rv.Len())
for i := range rv.Len() {
elem := rv.Index(i).Interface()
if _, ok := seen[elem]; ok {
return newValidationError(ErrorCodeInvalidFormat, "must not contain duplicates")
}
seen[elem] = struct{}{}
}
return nil
}
}
// NotEmpty validates that a field is not empty.
// Similar to Required, but can be used independently.
func NotEmpty() ValidatorFunc {

View File

@@ -264,3 +264,65 @@ func TestRequired(t *testing.T) {
}
})
}
func TestNoDuplicates(t *testing.T) {
t.Run("nil slice", func(t *testing.T) {
var slice []string
err := NoDuplicates()(slice)
if err != nil {
t.Errorf("expected no error for nil slice, got: %v", err)
}
})
t.Run("empty slice", func(t *testing.T) {
slice := []string{}
err := NoDuplicates()(slice)
if err != nil {
t.Errorf("expected no error for empty slice, got: %v", err)
}
})
t.Run("unique strings", func(t *testing.T) {
slice := []string{"a", "b", "c"}
err := NoDuplicates()(slice)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duplicate strings", func(t *testing.T) {
slice := []string{"a", "b", "a"}
err := NoDuplicates()(slice)
if err == nil {
t.Fatal("expected validation error for duplicates")
} else if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
t.Run("unique ints", func(t *testing.T) {
slice := []int{1, 2, 3}
err := NoDuplicates()(slice)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duplicate ints", func(t *testing.T) {
slice := []int{1, 2, 1}
err := NoDuplicates()(slice)
if err == nil {
t.Fatal("expected validation error for duplicates")
}
})
t.Run("non-comparable elements", func(t *testing.T) {
slice := []map[string]string{{"a": "b"}}
err := NoDuplicates()(slice)
if err == nil {
t.Fatal("expected validation error for non-comparable elements")
} else if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
}