Files
sendico/api/pkg/model/property.go
Stephan D 62a6631b9a
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
service backend
2025-11-07 18:35:26 +01:00

672 lines
19 KiB
Go

package model
import (
"fmt"
"math/big"
"regexp"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
)
// ----------------------------
// Core discriminant/type
// ----------------------------
type PropertyType = string
const (
PTDateTime PropertyType = "date_time"
PTInteger PropertyType = "integer"
PTFloat PropertyType = "float"
PTMonetary PropertyType = "monetary"
PTReference PropertyType = "reference"
PTString PropertyType = "string"
PTColor PropertyType = "color"
PTObject PropertyType = "object"
)
// Value keys for SettingsT maps
const (
VKString = "string"
VKStrings = "strings"
VKColor = "color"
VKColors = "colors"
VKInteger = "integer"
VKIntegers = "integers"
VKFloat = "float"
VKFloats = "floats"
VKDateTime = "date_time"
VKDateTimes = "date_times"
VKMonetary = "monetary"
VKMonetaries = "monetaries"
VKReference = "reference"
VKReferences = "references"
VKObject = "object"
VKObjects = "objects"
)
// Money struct field keys
const (
MKAmount = "amount"
MKCurrency = "currency"
)
// ----------------------------
// Small value types (runtime values)
// ----------------------------
// ----------------------------
// Type-specific PROPS (schema/constraints)
// ----------------------------
type IntegerProps struct {
Default *int64 `bson:"default,omitempty" json:"default,omitempty"`
Min *int64 `bson:"min,omitempty" json:"min,omitempty"`
Max *int64 `bson:"max,omitempty" json:"max,omitempty"`
Allowed []int64 `bson:"allowed,omitempty" json:"allowed,omitempty"`
}
type FloatProps struct {
Default *float64 `bson:"default,omitempty" json:"default,omitempty"`
Min *float64 `bson:"min,omitempty" json:"min,omitempty"`
Max *float64 `bson:"max,omitempty" json:"max,omitempty"`
}
type StringProps struct {
Default *string `bson:"default,omitempty" json:"default,omitempty"`
Allowed []string `bson:"allowed,omitempty" json:"allowed,omitempty"`
Pattern string `bson:"pattern" json:"pattern"` // Go RE2 syntax
MinLen *int `bson:"minLen,omitempty" json:"minLen,omitempty"`
MaxLen *int `bson:"maxLen,omitempty" json:"maxLen,omitempty"`
}
type DateTimeProps struct {
Default *time.Time `bson:"default,omitempty" json:"default,omitempty"` // store UTC
Earliest *time.Time `bson:"earliest,omitempty" json:"earliest,omitempty"`
Latest *time.Time `bson:"latest,omitempty" json:"latest,omitempty"`
}
type ColorProps struct {
AllowAlpha bool `bson:"allowAlpha,omitempty" json:"allowAlpha,omitempty"`
AllowedPalette []string `bson:"allowedPalette,omitempty" json:"allowedPalette,omitempty"` // optional whitelist of hex colors
Default string `bson:"default,omitempty" json:"default,omitempty"`
}
type ObjectProps struct {
Properties []PropertySchema `bson:"properties,omitempty" json:"properties,omitempty"`
}
// Currency policy for monetary props.
type CurrencyMode string
const (
CurrencyFixed CurrencyMode = "fixed" // force one currency (FixedCurrency)
CurrencyOrg CurrencyMode = "org" // force org default currency at runtime
CurrencyFree CurrencyMode = "free" // allow any (optionally restricted by AllowedCurrencies)
)
type MonetaryProps struct {
CurrencyMode CurrencyMode `bson:"currencyMode" json:"currencyMode"`
FixedCurrency Currency `bson:"fixedCurrency" json:"fixedCurrency"` // required if fixed
AllowedCurrencies []Currency `bson:"allowedCurrencies" json:"allowedCurrencies"` // for free mode
// Optional precision/rules; if nil, infer elsewhere by ISO minor units.
Scale *int `bson:"scale,omitempty" json:"scale,omitempty"` // allowed decimal places
Rounding *int `bson:"rounding,omitempty" json:"rounding,omitempty"` // app-specific; not enforced here
Default *Money `bson:"default,omitempty" json:"default,omitempty"`
Min *Money `bson:"min,omitempty" json:"min,omitempty"`
Max *Money `bson:"max,omitempty" json:"max,omitempty"`
}
type ReferenceProps struct {
Target mservice.Type `bson:"target" json:"target"` // e.g. "accounts"
AllowedIDs []primitive.ObjectID `bson:"allowedIds,omitempty" json:"allowedIds,omitempty"` // optional whitelist
Default *primitive.ObjectID `bson:"default,omitempty" json:"default,omitempty"` // optional default VALUE
}
// ----------------------------
// UI hints (optional)
// ----------------------------
type UIHints struct {
Placeholder string `bson:"placeholder" json:"placeholder"`
Unit string `bson:"unit" json:"unit"` // "kg", "cm", "€", etc.
HiddenInList bool `bson:"hiddenInList" json:"hiddenInList"`
Filterable bool `bson:"filterable" json:"filterable"`
}
// ----------------------------
// Multiplicity (generic, applies to any type)
// ----------------------------
type Cardinality string
const (
One Cardinality = "one" // single value
Many Cardinality = "many" // array of values
)
type Multiplicity struct {
Mode Cardinality `bson:"mode" json:"mode"` // default "one"
MinItems *int `bson:"minItems,omitempty" json:"minItems,omitempty"` // only when Mode=Many
MaxItems *int `bson:"maxItems,omitempty" json:"maxItems,omitempty"` // only when Mode=Many
// Distinct within one entity's list value (meaningful for Mode=Many).
Distinct bool `bson:"distinct" json:"distinct"`
}
// ----------------------------
// Property envelope
// ----------------------------
type PropertySchema struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
// customer permission refernece
ValuePermissionRef *primitive.ObjectID `bson:"valuePermissionRef,omitempty" json:"valuePermissionRef,omitempty"`
// Stable machine key; unique within (organizatoinRef, type, key)
Key string `bson:"key" json:"key"`
Type PropertyType `bson:"type" json:"type"`
// Lifecycle/UX
System bool `bson:"system" json:"system"`
UI *UIHints `bson:"ui,omitempty" json:"ui,omitempty"`
// Multiplicity controls (cross-type).
Multiplicity Multiplicity `bson:"multiplicity" json:"multiplicity"`
// Discriminated payload; a BSON subdocument shaped per Type.
Props any `bson:"props" json:"props"`
}
func (*PropertySchema) Collection() string { return mservice.PropertySchemas }
// ----------------------------
// Typed accessors for Props
// ----------------------------
func invalidType(expected, actual PropertyType) error {
return merrors.InvalidDataType(fmt.Sprintf("expected type is %s while actual type is %s", expected, actual))
}
// asTypedProps is a generic function that handles type checking and casting for all property types
func asTypedProps[T any](p *PropertySchema, expectedType PropertyType) (T, error) {
var out T
if p.Type != expectedType {
return out, invalidType(expectedType, p.Type)
}
// Props is stored directly as the correct type, so we can cast it
if props, ok := p.Props.(T); ok {
return props, nil
}
return out, merrors.InvalidArgument("invalid props type")
}
// Type-specific accessor functions using the generic template
func (p *PropertySchema) AsInteger() (IntegerProps, error) {
return asTypedProps[IntegerProps](p, PTInteger)
}
func (p *PropertySchema) AsFloat() (FloatProps, error) {
return asTypedProps[FloatProps](p, PTFloat)
}
func (p *PropertySchema) AsString() (StringProps, error) {
return asTypedProps[StringProps](p, PTString)
}
func (p *PropertySchema) AsDateTime() (DateTimeProps, error) {
return asTypedProps[DateTimeProps](p, PTDateTime)
}
func (p *PropertySchema) AsMonetary() (MonetaryProps, error) {
return asTypedProps[MonetaryProps](p, PTMonetary)
}
func (p *PropertySchema) AsReference() (ReferenceProps, error) {
return asTypedProps[ReferenceProps](p, PTReference)
}
func (p *PropertySchema) AsColor() (ColorProps, error) {
return asTypedProps[ColorProps](p, PTColor)
}
func (p *PropertySchema) AsObject() (ObjectProps, error) {
return asTypedProps[ObjectProps](p, PTObject)
}
// ----------------------------
// Validation helpers (generic)
// ----------------------------
func validateMultiplicity(count int, required bool, m Multiplicity) error {
mode := m.Mode
if mode == "" {
mode = One
}
switch mode {
case One:
if count > 1 {
return merrors.DataConflict("multiple values not allowed")
}
if required && count == 0 {
return merrors.DataConflict("value required")
}
case Many:
min := 0
if m.MinItems != nil {
min = *m.MinItems
} else if required {
min = 1
}
if count < min {
return merrors.DataConflict(fmt.Sprintf("minimum %d items", min))
}
if m.MaxItems != nil && count > *m.MaxItems {
return merrors.DataConflict(fmt.Sprintf("maximum %d items", *m.MaxItems))
}
default:
return merrors.InvalidArgument(fmt.Sprintf("unknown cardinality: %q", mode))
}
return nil
}
func ensureDistinct[T comparable](vals []T, distinct bool) error {
if !distinct || len(vals) < 2 {
return nil
}
seen := make(map[T]struct{}, len(vals))
for _, v := range vals {
if _, ok := seen[v]; ok {
return merrors.DataConflict("duplicate items not allowed")
}
seen[v] = struct{}{}
}
return nil
}
func ensureDistinctByKey[T any, K comparable](vals []T, key func(T) K, distinct bool) error {
if !distinct || len(vals) < 2 {
return nil
}
seen := make(map[K]struct{}, len(vals))
for _, v := range vals {
k := key(v)
if _, ok := seen[k]; ok {
return merrors.DataConflict("duplicate items not allowed")
}
seen[k] = struct{}{}
}
return nil
}
// ----------------------------
// Type validators
// ----------------------------
func (p PropertySchema) ValidateStrings(vals []string) error {
if p.Type != PTString {
return invalidType(PTString, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
if err := ensureDistinct(vals, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsString()
if err != nil {
return err
}
var re *regexp.Regexp
if props.Pattern != "" {
rx, rxErr := regexp.Compile(props.Pattern)
if rxErr != nil {
return merrors.InvalidArgument(fmt.Sprintf("invalid pattern: %v", rxErr))
}
re = rx
}
allow := map[string]struct{}{}
if len(props.Allowed) > 0 {
for _, a := range props.Allowed {
allow[a] = struct{}{}
}
}
for _, v := range vals {
if len(allow) > 0 {
if _, ok := allow[v]; !ok {
return merrors.DataConflict(fmt.Sprintf("value %q not allowed", v))
}
}
if props.MinLen != nil && len(v) < *props.MinLen {
return merrors.DataConflict(fmt.Sprintf("value too short (min %d)", *props.MinLen))
}
if props.MaxLen != nil && len(v) > *props.MaxLen {
return merrors.DataConflict(fmt.Sprintf("value too long (max %d)", *props.MaxLen))
}
if re != nil && !re.MatchString(v) {
return merrors.DataConflict(fmt.Sprintf("value %q does not match pattern", v))
}
}
return nil
}
func (p PropertySchema) ValidateColors(vals []string) error {
if p.Type != PTColor {
return invalidType(PTColor, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
if err := ensureDistinct(vals, p.Multiplicity.Distinct); err != nil {
return err
}
_, err := p.AsColor()
if err != nil {
return err
}
// For now, we can use the same validation as strings
// In the future, we might want to add color-specific validation
return nil
}
func (p PropertySchema) ValidateIntegers(vals []int64) error {
if p.Type != PTInteger {
return invalidType(PTInteger, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
if err := ensureDistinct(vals, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsInteger()
if err != nil {
return err
}
allow := map[int64]struct{}{}
if len(props.Allowed) > 0 {
for _, a := range props.Allowed {
allow[a] = struct{}{}
}
}
for _, v := range vals {
if len(allow) > 0 {
if _, ok := allow[v]; !ok {
return merrors.DataConflict(fmt.Sprintf("value %d not allowed", v))
}
}
if props.Min != nil && v < *props.Min {
return merrors.DataConflict(fmt.Sprintf("value %d below min %d", v, *props.Min))
}
if props.Max != nil && v > *props.Max {
return merrors.DataConflict(fmt.Sprintf("value %d above max %d", v, *props.Max))
}
}
return nil
}
func (p PropertySchema) ValidateFloats(vals []float64) error {
if p.Type != PTFloat {
return invalidType(PTFloat, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
if err := ensureDistinct(vals, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsFloat()
if err != nil {
return err
}
for _, v := range vals {
if props.Min != nil && v < *props.Min {
return merrors.DataConflict(fmt.Sprintf("value %g below min %g", v, *props.Min))
}
if props.Max != nil && v > *props.Max {
return merrors.DataConflict(fmt.Sprintf("value %g above max %g", v, *props.Max))
}
}
return nil
}
func (p PropertySchema) ValidateDateTimes(vals []time.Time) error {
if p.Type != PTDateTime {
return invalidType(PTDateTime, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
// Distinct datetimes rarely matter; honor it if requested.
if err := ensureDistinctByKey(vals, func(t time.Time) int64 { return t.UTC().UnixNano() }, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsDateTime()
if err != nil {
return err
}
for _, v := range vals {
vu := v.UTC()
if props.Earliest != nil && vu.Before(props.Earliest.UTC()) {
return merrors.DataConflict("datetime before earliest")
}
if props.Latest != nil && vu.After(props.Latest.UTC()) {
return merrors.DataConflict("datetime after latest")
}
}
return nil
}
// Monetary validation (handles currency policy + Min/Max + optional scale)
func (p PropertySchema) ValidateMonetaries(vals []Money, orgCurrency Currency) error {
if p.Type != PTMonetary {
return invalidType(PTMonetary, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
// Distinct by (currency, amount)
if err := ensureDistinctByKey(vals, func(m Money) string { return string(m.Currency) + "|" + m.Amount.String() }, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsMonetary()
if err != nil {
return err
}
allowedCur := map[Currency]struct{}{}
if len(props.AllowedCurrencies) > 0 {
for _, c := range props.AllowedCurrencies {
allowedCur[c] = struct{}{}
}
}
for _, v := range vals {
// Currency policy
switch props.CurrencyMode {
case CurrencyFixed:
if props.FixedCurrency == "" {
return merrors.InvalidArgument("fixed currency is not configured")
}
if v.Currency != props.FixedCurrency {
return merrors.DataConflict(fmt.Sprintf("currency must be %s", props.FixedCurrency))
}
case CurrencyOrg:
if orgCurrency == "" {
return merrors.InvalidArgument("org currency not provided")
}
if v.Currency != Currency(orgCurrency) {
return merrors.DataConflict(fmt.Sprintf("currency must be %s", orgCurrency))
}
case CurrencyFree, "":
if len(allowedCur) > 0 {
if _, ok := allowedCur[v.Currency]; !ok {
return merrors.DataConflict(fmt.Sprintf("currency %s not allowed", v.Currency))
}
}
default:
return merrors.InvalidArgument(fmt.Sprintf("unknown currency mode: %s", props.CurrencyMode))
}
// Scale check (if configured)
if props.Scale != nil {
ok, frac := decimal128WithinScale(v.Amount, *props.Scale)
if !ok {
return merrors.DataConflict(fmt.Sprintf("too many decimal places: got %d, max %d", frac, *props.Scale))
}
}
// Min/Max (apply only if currencies match)
if props.Min != nil && props.Min.Currency == v.Currency {
cmp, cmpErr := compareDecimal128(v.Amount, props.Min.Amount)
if cmpErr == nil && cmp < 0 {
return merrors.DataConflict("amount below min")
}
}
if props.Max != nil && props.Max.Currency == v.Currency {
cmp, cmpErr := compareDecimal128(v.Amount, props.Max.Amount)
if cmpErr == nil && cmp > 0 {
return merrors.DataConflict("amount above max")
}
}
}
return nil
}
// References: existence check is injected.
type ExistFn func(resource mservice.Type, id primitive.ObjectID, filter bson.M) (bool, error)
func (p PropertySchema) ValidateReferences(vals []primitive.ObjectID, exist ExistFn) error {
if p.Type != PTReference {
return invalidType(PTReference, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
props, err := p.AsReference()
if err != nil {
return err
}
// Distinct by referenced ID (and resource)
if err := ensureDistinctByKey(vals, func(r primitive.ObjectID) string { return props.Target + ":" + r.Hex() }, p.Multiplicity.Distinct); err != nil {
return err
}
allowed := map[primitive.ObjectID]struct{}{}
if len(props.AllowedIDs) > 0 {
for _, id := range props.AllowedIDs {
allowed[id] = struct{}{}
}
}
for _, v := range vals {
if len(allowed) > 0 {
if _, ok := allowed[v]; !ok {
return merrors.DataConflict(fmt.Sprintf("id %s not allowed", v.Hex()))
}
}
if exist != nil {
ok, exErr := exist(props.Target, v, bson.M{})
if exErr != nil {
return exErr
}
if !ok {
return merrors.DataConflict("referenced document not found or disallowed")
}
}
}
return nil
}
// ----------------------------
// Decimal128 utilities
// ----------------------------
// compareDecimal128 returns -1 if a < b, 0 if a == b, 1 if a > b.
func compareDecimal128(a, b primitive.Decimal128) (int, error) {
as := a.String()
bs := b.String()
af, _, err := big.ParseFloat(as, 10, 128, big.ToNearestEven)
if err != nil {
return 0, merrors.InvalidArgument(err.Error())
}
bf, _, err := big.ParseFloat(bs, 10, 128, big.ToNearestEven)
if err != nil {
return 0, merrors.InvalidArgument(err.Error())
}
return af.Cmp(bf), nil
}
// decimal128WithinScale checks if the number of fractional digits is <= scale.
func decimal128WithinScale(d primitive.Decimal128, scale int) (ok bool, fracDigits int) {
// Normalize via big.Float to handle exponents; then trim trailing zeros.
s := d.String()
f, _, err := big.ParseFloat(s, 10, 128, big.ToNearestEven)
if err != nil {
fd := countFractionDigits(s)
return fd <= scale, fd
}
fixed := f.Text('f', 40) // enough precision
fixed = trimTrailingZeros(fixed)
fd := countFractionDigits(fixed)
return fd <= scale, fd
}
func countFractionDigits(s string) int {
dot := -1
for i := 0; i < len(s); i++ {
if s[i] == '.' {
dot = i
break
}
}
if dot < 0 {
return 0
}
return len(s) - dot - 1
}
func trimTrailingZeros(s string) string {
dot := -1
for i := 0; i < len(s); i++ {
if s[i] == '.' {
dot = i
break
}
}
if dot < 0 {
return s
}
j := len(s) - 1
for j > dot && s[j] == '0' {
j--
}
if j == dot {
return s[:dot]
}
return s[:j+1]
}