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] }