From 3546e6d283ca79be577e0b24f0096637107adeb5 Mon Sep 17 00:00:00 2001 From: Indrajaya Date: Sun, 4 Oct 2026 04:14:40 +0000 Subject: [PATCH 01/13] Add SUM-PLAT-18: currency rates, UoM models, and conversion helpers Platform primitives for transactional verticals (issue #57): - Move core.currency from addons/geo to addons/base so the catalog, its rates, and the UoM master data ship as one always-on platform unit. - Add core.currency.rate (currency, rate, date_from, active) with (currency, date_from) natural keys for idempotent data syncs. - Add uom.category and uom.uom (factor, rounding) with (name, category) natural keys. - Add sdk.ConvertCurrency / sdk.ConvertUom / sdk.ConvertMoneyField: ConvertMoneyField resolves the currency= tag from the model registry, so ORM float/money fields tagged as currency convert via the newest effective rate for the requested date. - Seed data + admin views/menus/actions in addons/base (no accounting app): 8 currencies with bootstrap rates, 5 UoM categories, 16 units. - Tests: pure math unit tests, addon registration tests, integration tests for conversions, and an isolated Addon.SyncToDB regression test covering cross-record refs and natural-key upserts. Co-Authored-By: Claude Code --- addons/base/data/core_currency_rate_data.xml | 25 ++ addons/base/data/uom_data.xml | 37 +++ addons/base/manifest.json | 11 + addons/{geo => base}/models/core_currency.go | 2 + addons/base/models/core_currency_rate.go | 21 ++ addons/base/models/regional_refs.go | 1 - addons/base/models/uom.go | 27 +++ addons/base/models/zmodels.go | 4 + .../base/views/core_currency_form_views.xml | 14 ++ .../base/views/core_currency_list_views.xml | 10 + .../views/core_currency_rate_form_views.xml | 15 ++ .../views/core_currency_rate_list_views.xml | 11 + addons/base/views/currency_uom_actions.xml | 9 + addons/base/views/menus.xml | 17 ++ addons/base/views/uom_category_form_views.xml | 12 + addons/base/views/uom_category_list_views.xml | 8 + addons/base/views/uom_uom_form_views.xml | 16 ++ addons/base/views/uom_uom_list_views.xml | 12 + addons/geo/manifest.json | 2 +- addons/geo/models/zmodels.go | 1 - core/module/data_sync_records.go | 22 ++ core/sdk/convert.go | 224 ++++++++++++++++++ test/addons/base/models/currency_uom_test.go | 72 ++++++ test/core/sdk/convert_test.go | 97 ++++++++ test/integration/currency_uom_convert_test.go | 221 +++++++++++++++++ .../base_sync/base_data_sync_test.go | 146 ++++++++++++ 26 files changed, 1034 insertions(+), 3 deletions(-) create mode 100644 addons/base/data/core_currency_rate_data.xml create mode 100644 addons/base/data/uom_data.xml rename addons/{geo => base}/models/core_currency.go (71%) create mode 100644 addons/base/models/core_currency_rate.go create mode 100644 addons/base/models/uom.go create mode 100644 addons/base/views/core_currency_form_views.xml create mode 100644 addons/base/views/core_currency_list_views.xml create mode 100644 addons/base/views/core_currency_rate_form_views.xml create mode 100644 addons/base/views/core_currency_rate_list_views.xml create mode 100644 addons/base/views/currency_uom_actions.xml create mode 100644 addons/base/views/uom_category_form_views.xml create mode 100644 addons/base/views/uom_category_list_views.xml create mode 100644 addons/base/views/uom_uom_form_views.xml create mode 100644 addons/base/views/uom_uom_list_views.xml create mode 100644 core/sdk/convert.go create mode 100644 test/addons/base/models/currency_uom_test.go create mode 100644 test/core/sdk/convert_test.go create mode 100644 test/integration/currency_uom_convert_test.go create mode 100644 test/module/integration/base_sync/base_data_sync_test.go diff --git a/addons/base/data/core_currency_rate_data.xml b/addons/base/data/core_currency_rate_data.xml new file mode 100644 index 00000000..23b8c7ca --- /dev/null +++ b/addons/base/data/core_currency_rate_data.xml @@ -0,0 +1,25 @@ + + + + + USD$True + EUR€True + GBP£True + IDRRpTrue + SGDS$True + JPY¥True + INR₹True + AUDA$True + + + 1.02026-01-01True + 1.092026-01-01True + 1.272026-01-01True + 0.0000642026-01-01True + 0.742026-01-01True + 0.00672026-01-01True + 0.0122026-01-01True + 0.662026-01-01True + + diff --git a/addons/base/data/uom_data.xml b/addons/base/data/uom_data.xml new file mode 100644 index 00000000..667fb533 --- /dev/null +++ b/addons/base/data/uom_data.xml @@ -0,0 +1,37 @@ + + + + + Unit + Weight + Volume + Length + Time + + + Unit11True + Dozens121True + Hundreds1001True + + + kg10.001True + g0.0010.001True + mg0.0000010.001True + t10000.001True + + + l10.001True + ml0.0010.001True + m³10000.001True + + + m10.01True + cm0.010.01True + mm0.0010.01True + km10000.01True + + + Hours10.01True + Days80.01True + + diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 753acb68..e06e8c85 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -7,6 +7,8 @@ "application": true, "data": [ "data/core_company_data.xml", + "data/core_currency_rate_data.xml", + "data/uom_data.xml", "views/core_company_actions.xml", "views/core_users_actions.xml", "views/platform_actions.xml", @@ -34,6 +36,15 @@ "views/sys_rule_form_views.xml", "views/sys_field_access_list_views.xml", "views/sys_field_access_form_views.xml", + "views/currency_uom_actions.xml", + "views/core_currency_list_views.xml", + "views/core_currency_form_views.xml", + "views/core_currency_rate_list_views.xml", + "views/core_currency_rate_form_views.xml", + "views/uom_category_list_views.xml", + "views/uom_category_form_views.xml", + "views/uom_uom_list_views.xml", + "views/uom_uom_form_views.xml", "views/menus.xml" ] } diff --git a/addons/geo/models/core_currency.go b/addons/base/models/core_currency.go similarity index 71% rename from addons/geo/models/core_currency.go rename to addons/base/models/core_currency.go index e9eb4f74..0f572da7 100644 --- a/addons/geo/models/core_currency.go +++ b/addons/base/models/core_currency.go @@ -4,6 +4,8 @@ import ( "sumeru/core/sdk" ) +// CoreCurrency is the platform currency catalog. Rates are kept on +// core.currency.rate (see core_currency_rate.go). type CoreCurrency struct { sdk.Model `sumeru:"model=core.currency"` diff --git a/addons/base/models/core_currency_rate.go b/addons/base/models/core_currency_rate.go new file mode 100644 index 00000000..ee9c69ff --- /dev/null +++ b/addons/base/models/core_currency_rate.go @@ -0,0 +1,21 @@ +package models + +import ( + "sumeru/core/sdk" +) + +// CoreCurrencyRate holds the value of one unit of CurrencyID in the platform +// reference currency (the currency whose rate is 1.0), effective from DateFrom. +// +// Rates are global in this first iteration; verticals (e.g. accounting) can +// layer company-scoped rates and rate providers on top of this primitive. +// Lookups resolve the newest DateFrom <= the conversion date (see +// sdk.ConvertCurrency). +type CoreCurrencyRate struct { + sdk.Model `sumeru:"model=core.currency.rate"` + + CurrencyID sdk.Many2One[CoreCurrency] `sumeru:"required,index,string=Currency"` + Rate sdk.Float64 `sumeru:"required,string=Rate"` + DateFrom sdk.Date `sumeru:"required,index,string=Date From"` + Active sdk.Boolean `sumeru:"string=Active,default=true"` +} diff --git a/addons/base/models/regional_refs.go b/addons/base/models/regional_refs.go index a3167a73..60d92fc1 100644 --- a/addons/base/models/regional_refs.go +++ b/addons/base/models/regional_refs.go @@ -6,4 +6,3 @@ import geomodels "sumeru/addons/geo/models" type CoreCountry = geomodels.CoreCountry type CoreCountryState = geomodels.CoreCountryState type CoreCity = geomodels.CoreCity -type CoreCurrency = geomodels.CoreCurrency diff --git a/addons/base/models/uom.go b/addons/base/models/uom.go new file mode 100644 index 00000000..d5460a2a --- /dev/null +++ b/addons/base/models/uom.go @@ -0,0 +1,27 @@ +package models + +import ( + "sumeru/core/sdk" +) + +// UomCategory groups units of measure that can be converted between each +// other (e.g. Weight: kg, g, ton). +type UomCategory struct { + sdk.Model `sumeru:"model=uom.category"` + + Name sdk.String `sumeru:"required,unique,index,string=Category"` +} + +// UomUom is a unit of measure. Factor expresses how many reference units one +// unit of this UoM contains (e.g. in Weight with kg as reference: kg=1, +// g=0.001, ton=1000). Conversions inside a category divide the factors and +// round to the target UoM's Rounding precision (see sdk.ConvertUom). +type UomUom struct { + sdk.Model `sumeru:"model=uom.uom"` + + Name sdk.String `sumeru:"required,index,string=Unit of Measure"` + CategoryID sdk.Many2One[UomCategory] `sumeru:"required,index,string=Category"` + Factor sdk.Float64 `sumeru:"required,string=Ratio,default=1"` + Rounding sdk.Float64 `sumeru:"string=Rounding Precision,default=0.01"` + Active sdk.Boolean `sumeru:"string=Active,default=true"` +} diff --git a/addons/base/models/zmodels.go b/addons/base/models/zmodels.go index 6244ab1e..e534271b 100644 --- a/addons/base/models/zmodels.go +++ b/addons/base/models/zmodels.go @@ -7,6 +7,8 @@ import "sumeru/core/sdk" func init() { sdk.MustRegister("base", &CoreCompany{}, + &CoreCurrency{}, + &CoreCurrencyRate{}, &CoreGroup{}, &CorePartner{}, &CoreUser{}, @@ -17,5 +19,7 @@ func init() { &ResConfigSettings{}, &SysAuthProvider{}, &SysFieldAccess{}, + &UomCategory{}, + &UomUom{}, ) } diff --git a/addons/base/views/core_currency_form_views.xml b/addons/base/views/core_currency_form_views.xml new file mode 100644 index 00000000..d9ccf4f2 --- /dev/null +++ b/addons/base/views/core_currency_form_views.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/base/views/core_currency_list_views.xml b/addons/base/views/core_currency_list_views.xml new file mode 100644 index 00000000..ef98c564 --- /dev/null +++ b/addons/base/views/core_currency_list_views.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/base/views/core_currency_rate_form_views.xml b/addons/base/views/core_currency_rate_form_views.xml new file mode 100644 index 00000000..94f3fcc3 --- /dev/null +++ b/addons/base/views/core_currency_rate_form_views.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/addons/base/views/core_currency_rate_list_views.xml b/addons/base/views/core_currency_rate_list_views.xml new file mode 100644 index 00000000..b3566a79 --- /dev/null +++ b/addons/base/views/core_currency_rate_list_views.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/base/views/currency_uom_actions.xml b/addons/base/views/currency_uom_actions.xml new file mode 100644 index 00000000..a3bc9996 --- /dev/null +++ b/addons/base/views/currency_uom_actions.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 0724ab1a..7310bdf9 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -63,6 +63,23 @@ + + + + + + + diff --git a/addons/base/views/uom_category_form_views.xml b/addons/base/views/uom_category_form_views.xml new file mode 100644 index 00000000..ca29ed92 --- /dev/null +++ b/addons/base/views/uom_category_form_views.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/base/views/uom_category_list_views.xml b/addons/base/views/uom_category_list_views.xml new file mode 100644 index 00000000..e774164e --- /dev/null +++ b/addons/base/views/uom_category_list_views.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/addons/base/views/uom_uom_form_views.xml b/addons/base/views/uom_uom_form_views.xml new file mode 100644 index 00000000..dcc0e494 --- /dev/null +++ b/addons/base/views/uom_uom_form_views.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/addons/base/views/uom_uom_list_views.xml b/addons/base/views/uom_uom_list_views.xml new file mode 100644 index 00000000..769b092c --- /dev/null +++ b/addons/base/views/uom_uom_list_views.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/geo/manifest.json b/addons/geo/manifest.json index 41898a5d..015de7ab 100644 --- a/addons/geo/manifest.json +++ b/addons/geo/manifest.json @@ -3,7 +3,7 @@ "display_name": "Regional data", "version": "1.0.0", "author": "Sumeru", - "description": "Countries, states, cities, and currencies.", + "description": "Countries, states, and cities.", "depends": ["base"], "auto_install": true, "application": false, diff --git a/addons/geo/models/zmodels.go b/addons/geo/models/zmodels.go index 9398eeb6..ce438af8 100644 --- a/addons/geo/models/zmodels.go +++ b/addons/geo/models/zmodels.go @@ -9,6 +9,5 @@ func init() { &CoreCity{}, &CoreCountry{}, &CoreCountryState{}, - &CoreCurrency{}, ) } diff --git a/core/module/data_sync_records.go b/core/module/data_sync_records.go index 093065fc..e7d7e2b4 100644 --- a/core/module/data_sync_records.go +++ b/core/module/data_sync_records.go @@ -30,6 +30,8 @@ var recordSyncSpecs = map[string]recordSyncSpec{ "account.payment.term": {naturalKeys: naturalKeyName}, "account.move": {naturalKeys: naturalKeyName}, "core.partner": {naturalKeys: naturalKeyName}, + "core.currency.rate": {naturalKeys: naturalKeyCurrencyRate}, + "uom.uom": {naturalKeys: naturalKeyUom}, "account.journal": {naturalKeys: naturalKeyCode}, "account.move.line": {naturalKeys: naturalKeyMoveLine}, "mail.activity.type": {naturalKeys: naturalKeyName}, @@ -89,6 +91,26 @@ func naturalKeyReportAction(fieldValues map[string]interface{}) map[string]inter return map[string]interface{}{"name": fieldValues["name"]} } +// naturalKeyCurrencyRate keys core.currency.rate rows by (currency, date_from): +// one effective rate row per currency and start date. +func naturalKeyCurrencyRate(fieldValues map[string]interface{}) map[string]interface{} { + criteria := map[string]interface{}{"currency_id": fieldValues["currency_id"]} + if df, ok := fieldValues["date_from"]; ok && df != nil { + criteria["date_from"] = df + } + return criteria +} + +// naturalKeyUom keys uom.uom rows by (name, category): unit names are only +// unique inside their category (e.g. "oz" weight vs fluid ounce). +func naturalKeyUom(fieldValues map[string]interface{}) map[string]interface{} { + criteria := map[string]interface{}{"name": fieldValues["name"]} + if cid, ok := fieldValues["category_id"]; ok && cid != nil { + criteria["category_id"] = cid + } + return criteria +} + func syncGenericRegistryRecord(ctx context.Context, moduleName string, xmlRecord parser.Record) { if xmlRecord.Model == "sys.action.window" || xmlRecord.Model == "sys.action.url" || xmlRecord.Model == "sys.view" { return diff --git a/core/sdk/convert.go b/core/sdk/convert.go new file mode 100644 index 00000000..78090071 --- /dev/null +++ b/core/sdk/convert.go @@ -0,0 +1,224 @@ +package sdk + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + "time" + + "sumeru/core/orm" +) + +// dateLayout formats core.currency.rate.date_from values for domain filtering. +const dateLayout = "2006-01-02" + +// ConvertCurrency converts amount expressed in fromCurrencyID into +// toCurrencyID as of date, using for each currency the newest active +// core.currency.rate whose date_from is <= date. A zero date means today. +// +// Rates express the value of one unit of the currency in the platform +// reference currency (the currency whose rate is 1.0), so the math is +// amount * rateFrom / rateTo. +func ConvertCurrency(ctx context.Context, amount float64, fromCurrencyID, toCurrencyID int, date time.Time) (float64, error) { + if fromCurrencyID == toCurrencyID { + return amount, nil + } + if date.IsZero() { + date = time.Now() + } + rateFrom, err := currencyRate(ctx, fromCurrencyID, date) + if err != nil { + return 0, err + } + rateTo, err := currencyRate(ctx, toCurrencyID, date) + if err != nil { + return 0, err + } + return amount * rateFrom / rateTo, nil +} + +// currencyRate resolves the newest effective rate for one currency. +func currencyRate(ctx context.Context, currencyID int, date time.Time) (float64, error) { + domain := [][]interface{}{ + {"currency_id", "=", currencyID}, + {"date_from", "<=", date.Format(dateLayout)}, + {"active", "=", true}, + } + rows, err := orm.SearchPage(ctx, "core.currency.rate", domain, 10, 0, "date_from desc") + if err != nil { + return 0, fmt.Errorf("sdk: lookup currency rate for currency %d: %w", currencyID, err) + } + if len(rows) == 0 { + return 0, fmt.Errorf("sdk: no active core.currency.rate for currency %d on %s or earlier", + currencyID, date.Format(dateLayout)) + } + // Rows are ordered by date_from desc; on equal dates the highest id wins + // so the result is deterministic. + var best map[string]interface{} + for _, row := range rows { + if best == nil || rowKey(row) > rowKey(best) { + best = row + } + } + rate, ok := asFloat(best["rate"]) + if !ok { + return 0, fmt.Errorf("sdk: non-numeric rate %v on core.currency.rate row %v", best["rate"], best["id"]) + } + if rate <= 0 { + return 0, fmt.Errorf("sdk: invalid non-positive rate %v for currency %d", rate, currencyID) + } + return rate, nil +} + +// rowKey orders rate rows by (date_from, id) so ties on date_from resolve to +// the newest row. +func rowKey(row map[string]interface{}) string { + id, _ := asInt(row["id"]) + return fmt.Sprintf("%s|%020d", dateString(row["date_from"]), id) +} + +// ConvertUom converts a quantity from fromUomID into toUomID. Both units must +// belong to the same uom.category. The result is rounded to multiples of the +// target unit's rounding precision. +func ConvertUom(ctx context.Context, amount float64, fromUomID, toUomID int) (float64, error) { + if fromUomID == toUomID { + return amount, nil + } + rows, err := orm.Search(ctx, "uom.uom", [][]interface{}{{"id", "in", []interface{}{fromUomID, toUomID}}}) + if err != nil { + return 0, fmt.Errorf("sdk: lookup uom %d and %d: %w", fromUomID, toUomID, err) + } + var from, to map[string]interface{} + for _, row := range rows { + id, _ := asInt(row["id"]) + switch id { + case fromUomID: + from = row + case toUomID: + to = row + } + } + if from == nil || to == nil { + return 0, fmt.Errorf("sdk: uom not found (from=%d to=%d)", fromUomID, toUomID) + } + fromCat, _ := asInt(from["category_id"]) + toCat, _ := asInt(to["category_id"]) + if fromCat != toCat { + return 0, fmt.Errorf("sdk: cannot convert uom %d (category %d) to uom %d (category %d)", + fromUomID, fromCat, toUomID, toCat) + } + factorFrom, ok := asFloat(from["factor"]) + if !ok || factorFrom <= 0 { + return 0, fmt.Errorf("sdk: invalid factor on uom %d", fromUomID) + } + factorTo, ok := asFloat(to["factor"]) + if !ok || factorTo <= 0 { + return 0, fmt.Errorf("sdk: invalid factor on uom %d", toUomID) + } + rounding, _ := asFloat(to["rounding"]) + return ConvertUomByFactor(amount, factorFrom, factorTo, rounding), nil +} + +// ConvertUomByFactor applies the pure factor math of uom conversion: +// amount * factorFrom / factorTo, rounded to multiples of rounding (no-op +// when rounding <= 0). Exported so conversions can be unit-tested without a +// database and reused by verticals on preloaded records. +func ConvertUomByFactor(amount, factorFrom, factorTo, rounding float64) float64 { + out := amount * factorFrom / factorTo + if rounding > 0 { + out = math.Round(out/rounding) * rounding + } + return out +} + +// ConvertMoneyField converts the value of fieldName on rec (a record of +// modelName) into toCurrencyID as of date. fieldName must be tagged +// currency=; the sibling field's value on rec names +// the source currency. +func ConvertMoneyField(ctx context.Context, modelName, fieldName string, rec map[string]interface{}, toCurrencyID int, date time.Time) (float64, error) { + inst, ok := orm.Registry[modelName] + if !ok { + return 0, fmt.Errorf("sdk: unknown model %q", modelName) + } + var def orm.FieldDefinition + found := false + for _, f := range inst.Fields() { + if f.Name == fieldName { + def = f + found = true + break + } + } + if !found { + return 0, fmt.Errorf("sdk: model %s has no field %q", modelName, fieldName) + } + if def.Currency == "" { + return 0, fmt.Errorf("sdk: field %s.%s is not tagged currency=", modelName, fieldName) + } + fromID, ok := asInt(rec[def.Currency]) + if !ok || fromID == 0 { + return 0, fmt.Errorf("sdk: record has no %q value for %s.%s", def.Currency, modelName, fieldName) + } + amount, ok := asFloat(rec[fieldName]) + if !ok { + return 0, fmt.Errorf("sdk: record has no numeric value for %s.%s", modelName, fieldName) + } + return ConvertCurrency(ctx, amount, fromID, toCurrencyID, date) +} + +// dateString renders scanned DATE values (time.Time or driver string) back to +// the ISO date layout used for lexicographic comparison. +func dateString(v interface{}) string { + switch t := v.(type) { + case time.Time: + return t.Format(dateLayout) + case string: + return t + case []byte: + return string(t) + default: + return "" + } +} + +func asInt(v interface{}) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + case string: + i, err := strconv.Atoi(strings.TrimSpace(n)) + return i, err == nil + case []byte: + i, err := strconv.Atoi(strings.TrimSpace(string(n))) + return i, err == nil + default: + return 0, false + } +} + +func asFloat(v interface{}) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int64: + return float64(n), true + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(n), 64) + return f, err == nil + case []byte: + f, err := strconv.ParseFloat(strings.TrimSpace(string(n)), 64) + return f, err == nil + default: + return 0, false + } +} diff --git a/test/addons/base/models/currency_uom_test.go b/test/addons/base/models/currency_uom_test.go new file mode 100644 index 00000000..f5b87293 --- /dev/null +++ b/test/addons/base/models/currency_uom_test.go @@ -0,0 +1,72 @@ +package models_test + +import ( + "context" + "testing" + "time" + + _ "sumeru/addons/base" + "sumeru/core/modelreg" + "sumeru/core/orm" + "sumeru/core/sdk" +) + +// TestCurrencyUomModelsRegistered verifies the SUM-PLAT-18 platform models are +// registered with the expected field shapes. +func TestCurrencyUomModelsRegistered(t *testing.T) { + _ = modelreg.ActivateAll([]string{"base"}) + ctx := context.Background() + for _, name := range []string{"core.currency", "core.currency.rate", "uom.category", "uom.uom"} { + if _, ok := orm.Registry[name]; !ok { + t.Fatalf("model %s not registered", name) + } + } + + rate, _ := orm.Registry["core.currency.rate"] + fields := map[string]orm.FieldDefinition{} + for _, f := range rate.Fields() { + fields[f.Name] = f + } + if f, ok := fields["currency_id"]; !ok || f.Type != orm.Many2One || f.Relation != "core.currency" { + t.Fatalf("core.currency.rate.currency_id = %+v, want many2one core.currency", fields["currency_id"]) + } + if f, ok := fields["rate"]; !ok || f.Type != orm.Float64 { + t.Fatalf("core.currency.rate.rate = %+v, want float64", fields["rate"]) + } + if f, ok := fields["date_from"]; !ok || f.Type != orm.Date { + t.Fatalf("core.currency.rate.date_from = %+v, want date", fields["date_from"]) + } + + uom, _ := orm.Registry["uom.uom"] + fields = map[string]orm.FieldDefinition{} + for _, f := range uom.Fields() { + fields[f.Name] = f + } + if f, ok := fields["category_id"]; !ok || f.Type != orm.Many2One || f.Relation != "uom.category" { + t.Fatalf("uom.uom.category_id = %+v, want many2one uom.category", fields["category_id"]) + } + if f, ok := fields["factor"]; !ok || f.Type != orm.Float64 { + t.Fatalf("uom.uom.factor = %+v, want float64", fields["factor"]) + } + if f, ok := fields["rounding"]; !ok || f.Type != orm.Float64 { + t.Fatalf("uom.uom.rounding = %+v, want float64", fields["rounding"]) + } + + // The currency tag must keep flowing from struct tag to field definition. + currency, _ := orm.Registry["core.currency"] + hasName := false + for _, f := range currency.Fields() { + if f.Name == "name" && f.Type == orm.Char { + hasName = true + } + } + if !hasName { + t.Fatal("core.currency.name missing") + } + + // ConvertMoneyField against a real base model field without currency tag. + _, err := sdk.ConvertMoneyField(ctx, "core.user", "name", map[string]interface{}{"name": "x"}, 1, time.Time{}) + if err == nil { + t.Fatal("expected error: core.user.name is not currency-tagged") + } +} diff --git a/test/core/sdk/convert_test.go b/test/core/sdk/convert_test.go new file mode 100644 index 00000000..3ba30bb0 --- /dev/null +++ b/test/core/sdk/convert_test.go @@ -0,0 +1,97 @@ +package sdk_test + +import ( + "context" + "math" + "testing" + "time" + + "sumeru/core/orm" + "sumeru/core/sdk" +) + +func TestConvertUomByFactorMath(t *testing.T) { + cases := []struct { + name string + amount, from, to, rounding float64 + want float64 + }{ + {"kg to g", 1, 1, 0.001, 0.001, 1000}, + {"g to kg", 500, 0.001, 1, 0.001, 0.5}, + {"dozen to unit", 2, 12, 1, 1, 24}, + {"unit to dozen rounding", 13, 1, 12, 1, 1}, + {"no rounding when zero", 1.0 / 3, 1, 1, 0, 1.0 / 3}, + {"round to two decimals", 1, 1, 3, 0.01, 0.33}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sdk.ConvertUomByFactor(tc.amount, tc.from, tc.to, tc.rounding) + if math.Abs(got-tc.want) > 1e-9 { + t.Fatalf("ConvertUomByFactor(%v,%v,%v,%v) = %v, want %v", + tc.amount, tc.from, tc.to, tc.rounding, got, tc.want) + } + }) + } +} + +// Fast paths must not touch the database. +func TestConvertCurrencySameCurrencyNoDB(t *testing.T) { + ctx := context.Background() + got, err := sdk.ConvertCurrency(ctx, 123.45, 7, 7, time.Time{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 123.45 { + t.Fatalf("got %v, want 123.45", got) + } +} + +func TestConvertUomSameUnitNoDB(t *testing.T) { + ctx := context.Background() + got, err := sdk.ConvertUom(ctx, 42, 3, 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 42 { + t.Fatalf("got %v, want 42", got) + } +} + +func TestConvertMoneyFieldUnknownModel(t *testing.T) { + _, err := sdk.ConvertMoneyField(context.Background(), "no.such.model", "amount", nil, 1, time.Time{}) + if err == nil { + t.Fatal("expected error for unknown model") + } +} + +func TestConvertMoneyFieldRequiresCurrencyTag(t *testing.T) { + orm.RegisterStubModelForTest(t, "stub.plain", []orm.FieldDefinition{ + {Name: "amount", Type: orm.Float64}, + }) + _, err := sdk.ConvertMoneyField(context.Background(), "stub.plain", "amount", map[string]interface{}{"amount": 10}, 1, time.Time{}) + if err == nil { + t.Fatal("expected error for field without currency tag") + } +} + +func TestConvertMoneyFieldRequiresSiblingValue(t *testing.T) { + orm.RegisterStubModelForTest(t, "stub.money", []orm.FieldDefinition{ + {Name: "amount", Type: orm.Float64, Currency: "currency_id"}, + }) + rec := map[string]interface{}{"amount": 10} + _, err := sdk.ConvertMoneyField(context.Background(), "stub.money", "amount", rec, 1, time.Time{}) + if err == nil { + t.Fatal("expected error for missing sibling currency value") + } +} + +func TestConvertMoneyFieldRequiresNumericAmount(t *testing.T) { + orm.RegisterStubModelForTest(t, "stub.money2", []orm.FieldDefinition{ + {Name: "amount", Type: orm.Float64, Currency: "currency_id"}, + }) + rec := map[string]interface{}{"amount": "not-a-number", "currency_id": 7} + _, err := sdk.ConvertMoneyField(context.Background(), "stub.money2", "amount", rec, 1, time.Time{}) + if err == nil { + t.Fatal("expected error for non-numeric amount") + } +} diff --git a/test/integration/currency_uom_convert_test.go b/test/integration/currency_uom_convert_test.go new file mode 100644 index 00000000..aeb67de4 --- /dev/null +++ b/test/integration/currency_uom_convert_test.go @@ -0,0 +1,221 @@ +//go:build integration + +package integration_test + +import ( + "context" + "fmt" + "math" + "os" + "testing" + "time" + + _ "sumeru/addons/base" + "sumeru/core/modelreg" + "sumeru/core/orm" + "sumeru/core/sdk" +) + +const rateDateLayout = "2006-01-02" + +func setupCurrencyUom(t *testing.T) context.Context { + t.Helper() + dsn := os.Getenv("SUMERU_TEST_DSN") + if dsn == "" { + t.Skip("SUMERU_TEST_DSN not set") + } + orm.InitDB(dsn) + _ = modelreg.ActivateAll([]string{"base"}) + if err := orm.SyncRegistrySchemaForNames([]string{ + "core.currency", "core.currency.rate", "uom.category", "uom.uom", + }); err != nil { + t.Fatalf("schema sync: %v", err) + } + return orm.ContextWithBypass(context.Background(), true) +} + +// runSuffix keeps record names unique per test run so repeated runs against a +// shared database never collide on core.currency.name / uom.category.name. +var runSuffix = fmt.Sprintf("%d", time.Now().UnixNano()) + +func createCurrency(t *testing.T, ctx context.Context, name, symbol string) int { + t.Helper() + id, err := orm.Create(ctx, orm.Registry["core.currency"], map[string]interface{}{ + "name": name + runSuffix, "symbol": symbol, "active": true, + }) + if err != nil { + t.Fatalf("create currency %s: %v", name, err) + } + t.Cleanup(func() { _ = orm.Unlink(ctx, "core.currency", id) }) + return id +} + +func createRate(t *testing.T, ctx context.Context, currencyID int, rate float64, dateFrom string) int { + t.Helper() + id, err := orm.Create(ctx, orm.Registry["core.currency.rate"], map[string]interface{}{ + "currency_id": currencyID, "rate": rate, "date_from": dateFrom, "active": true, + }) + if err != nil { + t.Fatalf("create rate for %d: %v", currencyID, err) + } + t.Cleanup(func() { _ = orm.Unlink(ctx, "core.currency.rate", id) }) + return id +} + +func createUom(t *testing.T, ctx context.Context, name string, categoryID int, factor, rounding float64) int { + t.Helper() + id, err := orm.Create(ctx, orm.Registry["uom.uom"], map[string]interface{}{ + "name": name + runSuffix, "category_id": categoryID, "factor": factor, + "rounding": rounding, "active": true, + }) + if err != nil { + t.Fatalf("create uom %s: %v", name, err) + } + t.Cleanup(func() { _ = orm.Unlink(ctx, "uom.uom", id) }) + return id +} + +func createUomCategory(t *testing.T, ctx context.Context, name string) int { + t.Helper() + id, err := orm.Create(ctx, orm.Registry["uom.category"], map[string]interface{}{"name": name + runSuffix}) + if err != nil { + t.Fatalf("create category %s: %v", name, err) + } + t.Cleanup(func() { _ = orm.Unlink(ctx, "uom.category", id) }) + return id +} + +func TestIntegrationConvertCurrency(t *testing.T) { + ctx := setupCurrencyUom(t) + usd := createCurrency(t, ctx, "TUSD", "$") + eur := createCurrency(t, ctx, "TEUR", "€") + idr := createCurrency(t, ctx, "TIDR", "Rp") + createRate(t, ctx, usd, 1.0, "2026-01-01") + createRate(t, ctx, eur, 1.09, "2026-01-01") + createRate(t, ctx, idr, 0.000064, "2026-01-01") + date := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + + got, err := sdk.ConvertCurrency(ctx, 100, usd, eur, date) + if err != nil { + t.Fatalf("USD->EUR: %v", err) + } + if math.Abs(got-91.7431192660) > 1e-6 { + t.Fatalf("USD->EUR = %v, want ~91.7431", got) + } + + got, err = sdk.ConvertCurrency(ctx, 100, eur, usd, date) + if err != nil { + t.Fatalf("EUR->USD: %v", err) + } + if math.Abs(got-109) > 1e-6 { + t.Fatalf("EUR->USD = %v, want 109", got) + } + + got, err = sdk.ConvertCurrency(ctx, 100, usd, idr, date) + if err != nil { + t.Fatalf("USD->IDR: %v", err) + } + if math.Abs(got-1562500) > 1e-6 { + t.Fatalf("USD->IDR = %v, want 1562500", got) + } +} + +func TestIntegrationConvertCurrencyUsesNewestRate(t *testing.T) { + ctx := setupCurrencyUom(t) + usd := createCurrency(t, ctx, "TUSD2", "$") + eur := createCurrency(t, ctx, "TEUR2", "€") + createRate(t, ctx, usd, 1.0, "2026-01-01") + createRate(t, ctx, eur, 1.09, "2026-01-01") + createRate(t, ctx, eur, 1.15, "2026-06-01") + + before, err := sdk.ConvertCurrency(ctx, 100, usd, eur, time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("before new rate: %v", err) + } + after, err := sdk.ConvertCurrency(ctx, 100, usd, eur, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("after new rate: %v", err) + } + if math.Abs(before-91.7431192660) > 1e-6 { + t.Fatalf("before = %v, want ~91.7431", before) + } + if math.Abs(after-86.9565217391) > 1e-6 { + t.Fatalf("after = %v, want ~86.9565", after) + } +} + +func TestIntegrationConvertCurrencyMissingRate(t *testing.T) { + ctx := setupCurrencyUom(t) + usd := createCurrency(t, ctx, "TUSD3", "$") + orphan := createCurrency(t, ctx, "TORPHAN", "O") + createRate(t, ctx, usd, 1.0, "2026-01-01") + + _, err := sdk.ConvertCurrency(ctx, 100, usd, orphan, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)) + if err == nil { + t.Fatal("expected error: no rate for orphan currency") + } +} + +func TestIntegrationConvertUom(t *testing.T) { + ctx := setupCurrencyUom(t) + catID := createUomCategory(t, ctx, "TWeight") + kg := createUom(t, ctx, "tkg", catID, 1, 0.001) + g := createUom(t, ctx, "tg", catID, 0.001, 0.001) + ton := createUom(t, ctx, "tton", catID, 1000, 0.001) + + got, err := sdk.ConvertUom(ctx, 1, kg, g) + if err != nil { + t.Fatalf("kg->g: %v", err) + } + if math.Abs(got-1000) > 1e-9 { + t.Fatalf("kg->g = %v, want 1000", got) + } + + got, err = sdk.ConvertUom(ctx, 500, g, kg) + if err != nil { + t.Fatalf("g->kg: %v", err) + } + if math.Abs(got-0.5) > 1e-9 { + t.Fatalf("g->kg = %v, want 0.5", got) + } + + got, err = sdk.ConvertUom(ctx, 1.5, ton, kg) + if err != nil { + t.Fatalf("ton->kg: %v", err) + } + if math.Abs(got-1500) > 1e-9 { + t.Fatalf("ton->kg = %v, want 1500", got) + } +} + +func TestIntegrationConvertUomCrossCategory(t *testing.T) { + ctx := setupCurrencyUom(t) + weightID := createUomCategory(t, ctx, "TWeight2") + lengthID := createUomCategory(t, ctx, "TLength") + kg := createUom(t, ctx, "tkg2", weightID, 1, 0.001) + m := createUom(t, ctx, "tm", lengthID, 1, 0.01) + + if _, err := sdk.ConvertUom(ctx, 1, kg, m); err == nil { + t.Fatal("expected error: cross-category conversion") + } +} + +func TestIntegrationConvertMoneyField(t *testing.T) { + ctx := setupCurrencyUom(t) + usd := createCurrency(t, ctx, "TUSD4", "$") + eur := createCurrency(t, ctx, "TEUR4", "€") + createRate(t, ctx, usd, 1.0, "2026-01-01") + createRate(t, ctx, eur, 1.09, "2026-01-01") + + orm.RegisterStubModelForTest(t, "stub.int.money", []orm.FieldDefinition{ + {Name: "amount", Type: orm.Float64, Currency: "currency_id"}, + }) + rec := map[string]interface{}{"amount": 100.0, "currency_id": eur} + got, err := sdk.ConvertMoneyField(ctx, "stub.int.money", "amount", rec, usd, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("ConvertMoneyField: %v", err) + } + if math.Abs(got-109) > 1e-6 { + t.Fatalf("ConvertMoneyField = %v, want 109 (100 EUR at 1.09 = 109 USD)", got) + } +} diff --git a/test/module/integration/base_sync/base_data_sync_test.go b/test/module/integration/base_sync/base_data_sync_test.go new file mode 100644 index 00000000..3ea0c94f --- /dev/null +++ b/test/module/integration/base_sync/base_data_sync_test.go @@ -0,0 +1,146 @@ +//go:build integration + +// Package base_sync integration-tests the module data sync path (Addon.SyncToDB) +// for the base addon in isolation from other integration test binaries, because +// activating kernel models changes access-check behavior for tests that search +// sys.* without a bypass context. +package base_sync_test + +import ( + "context" + "database/sql" + "math" + "os" + "path/filepath" + "regexp" + "testing" + + _ "sumeru/addons/base" + "sumeru/core/modelreg" + "sumeru/core/module" + "sumeru/core/orm" + _ "sumeru/core/ormmodels" + "sumeru/test/harness" +) + +// freshSyncTestDB points the ORM at a dedicated database that is dropped and +// recreated on every run, so the sync test never depends on (or disturbs) +// the shared test database and its assertions stay deterministic. +func freshSyncTestDB(t *testing.T, dsn string) string { + t.Helper() + const syncDBName = "sumeru_test_base_sync" + admin, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open admin connection: %v", err) + } + if _, err := admin.Exec("DROP DATABASE IF EXISTS " + syncDBName); err != nil { + t.Fatalf("drop %s: %v", syncDBName, err) + } + if _, err := admin.Exec("CREATE DATABASE " + syncDBName); err != nil { + t.Fatalf("create %s: %v", syncDBName, err) + } + _ = admin.Close() + + re := regexp.MustCompile(`\bdbname=\S+`) + if !re.MatchString(dsn) { + t.Fatalf("DSN has no dbname: %s", dsn) + } + return re.ReplaceAllString(dsn, "dbname="+syncDBName) +} + +// TestBaseAddonSyncToDBLoadsCurrencyUomData exercises Addon.SyncToDB with the +// base manifest XML: currencies, rates, uom categories/units, views, actions, +// and menus must all land in the database, with rate/uom records resolving +// cross-record refs and natural keys (regression test for the +// core.currency.rate / uom.uom recordSyncSpecs entries). +func TestBaseAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { + dsn := os.Getenv("SUMERU_TEST_DSN") + if dsn == "" { + t.Skip("SUMERU_TEST_DSN not set") + } + orm.InitDB(freshSyncTestDB(t, dsn)) + _ = modelreg.ActivateAll([]string{"base"}) + if err := orm.SyncRegistrySchemaForNames(orm.InitialSetupModelNames); err != nil { + t.Fatalf("kernel schema sync: %v", err) + } + if err := orm.SyncRegistrySchemaForModule("base"); err != nil { + t.Fatalf("base schema sync: %v", err) + } + root := harness.RepoRoot(t) + discovered, err := module.DiscoverAddonRoots([]string{filepath.Join(root, "addons")}) + if err != nil { + t.Fatalf("discover addons: %v", err) + } + addon, ok := discovered["base"] + if !ok { + t.Fatal("base addon not discovered") + } + ctx := orm.ContextWithBypass(context.Background(), true) + if err := addon.SyncToDB(ctx); err != nil { + t.Fatalf("base SyncToDB: %v", err) + } + + counts := map[string]struct { + model string + want int + }{ + "currencies": {"core.currency", 8}, + "rates": {"core.currency.rate", 8}, + "uom categories": {"uom.category", 5}, + "uom units": {"uom.uom", 16}, + } + for label, c := range counts { + n, err := orm.SearchCount(ctx, c.model, nil) + if err != nil { + t.Fatalf("count %s: %v", label, err) + } + if n != c.want { + t.Fatalf("%s: got %d rows, want %d", label, n, c.want) + } + } + + // Rate rows resolve the currency ref and carry the expected value. + eur, err := orm.SearchOne(ctx, "core.currency", map[string]interface{}{"name": "EUR"}) + if err != nil { + t.Fatalf("find EUR: %v", err) + } + eurID, _ := orm.CoerceInt64(eur["id"]) + rateRow, err := orm.SearchOne(ctx, "core.currency.rate", map[string]interface{}{ + "currency_id": int(eurID), "date_from": "2026-01-01", + }) + if err != nil { + t.Fatalf("find EUR rate: %v", err) + } + if rate, ok := rateRow["rate"].(float64); !ok || math.Abs(rate-1.09) > 1e-9 { + t.Fatalf("EUR rate = %v, want 1.09", rateRow["rate"]) + } + + // UoM rows resolve the category ref and carry the expected factor. + kg, err := orm.SearchOne(ctx, "uom.uom", map[string]interface{}{"name": "kg"}) + if err != nil { + t.Fatalf("find kg: %v", err) + } + if f, ok := kg["factor"].(float64); !ok || math.Abs(f-1) > 1e-9 { + t.Fatalf("kg factor = %v, want 1", kg["factor"]) + } + + // Admin UI metadata: views, window actions, and menus for the new models. + viewNames := []string{"view_core.currency.rate_list", "view_uom.uom_form"} + for _, name := range viewNames { + if _, err := orm.SearchOne(ctx, "sys.view", map[string]interface{}{"name": name}); err != nil { + t.Fatalf("sys.view %q missing: %v", name, err) + } + } + actionNames := []string{"Currencies", "Currency Rates", "UoM Categories", "Units of Measure"} + for _, name := range actionNames { + if _, err := orm.SearchOne(ctx, "sys.action.window", map[string]interface{}{"name": name}); err != nil { + t.Fatalf("sys.action.window %q missing: %v", name, err) + } + } + menus := []string{"Currencies & Units", "Currencies", "Currency Rates", "UoM Categories", "Units of Measure"} + for _, menu := range menus { + if _, err := orm.SearchOne(ctx, "sys.menu", map[string]interface{}{"name": menu}); err != nil { + t.Fatalf("menu %q missing: %v", menu, err) + } + } +} From b01a0cbd71e773005a754d77e8aabb1202c7882e Mon Sep 17 00:00:00 2001 From: Indrajaya Date: Sun, 4 Oct 2026 04:14:44 +0000 Subject: [PATCH 02/13] test: repair pre-existing integration test breakage test/module/integration/install_closure_test.go no longer compiled (orm.InitDB returns no value) and, with kernel models registered, searches on sys.module without a bypass context fail access checks. Give both integration smoke tests the model registry and a bypass context so they pass against an initialized database. Co-Authored-By: Claude Code --- test/integration/db_test.go | 5 ++++- test/module/integration/install_closure_test.go | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test/integration/db_test.go b/test/integration/db_test.go index 6e439cb8..5a03bedc 100644 --- a/test/integration/db_test.go +++ b/test/integration/db_test.go @@ -8,7 +8,9 @@ import ( "testing" "time" + "sumeru/core/modelreg" "sumeru/core/orm" + _ "sumeru/core/ormmodels" ) func TestIntegrationDBPing(t *testing.T) { @@ -20,7 +22,8 @@ func TestIntegrationDBPing(t *testing.T) { if !orm.IsInitialized() { t.Skip("database not initialized (run setup or apply schema first)") } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _ = modelreg.ActivateAll(nil) + ctx, cancel := context.WithTimeout(orm.ContextWithBypass(context.Background(), true), 10*time.Second) defer cancel() if _, err := orm.SearchLimit(ctx, "sys.module", nil, 5); err != nil { t.Fatalf("search sys.module: %v", err) diff --git a/test/module/integration/install_closure_test.go b/test/module/integration/install_closure_test.go index 437767be..5bf6837b 100644 --- a/test/module/integration/install_closure_test.go +++ b/test/module/integration/install_closure_test.go @@ -7,7 +7,9 @@ import ( "os" "testing" + "sumeru/core/modelreg" "sumeru/core/orm" + _ "sumeru/core/ormmodels" ) func TestIntegrationModuleRegistrySearchable(t *testing.T) { @@ -15,10 +17,9 @@ func TestIntegrationModuleRegistrySearchable(t *testing.T) { if dsn == "" { t.Skip("SUMERU_TEST_DSN not set") } - if err := orm.InitDB(dsn); err != nil { - t.Fatalf("InitDB: %v", err) - } - ctx := context.Background() + orm.InitDB(dsn) + _ = modelreg.ActivateAll(nil) + ctx := orm.ContextWithBypass(context.Background(), true) if _, err := orm.SearchLimit(ctx, "sys.module", nil, 5); err != nil { t.Fatalf("search sys.module: %v", err) } From d1b2fdf6e1cd60cfc76b8c77194dcb0922f6e73c Mon Sep 17 00:00:00 2001 From: Indrajaya Date: Wed, 7 Oct 2026 00:41:56 +0000 Subject: [PATCH 03/13] Remove seeded currency rates from core data (SUM-PLAT-18) Date-sensitive bootstrap rates are business data, not master data: a fresh production database should not be born with stale 2026-01-01 rates. The currency catalog and UoM data stay core; core.currency.rate rows are now created by verticals or rate providers, and the (currency, date_from) natural keys remain so verticals can ship their own rate data files idempotently. - Rename data/core_currency_rate_data.xml to core_currency_data.xml and drop the rate records (catalog only). - Document the non-seeding policy on the core.currency.rate model. - SyncToDB regression test now asserts the catalog and UoM data load with zero seeded rate rows. - Conversion tests load the i18n addon so schema sync can ensure the sys.translation index on already-installed databases. Co-Authored-By: Claude Code --- addons/base/data/core_currency_data.xml | 17 ++++++++++ addons/base/data/core_currency_rate_data.xml | 25 -------------- addons/base/manifest.json | 2 +- addons/base/models/core_currency_rate.go | 3 +- test/integration/currency_uom_convert_test.go | 5 ++- .../base_sync/base_data_sync_test.go | 33 ++++++++++--------- 6 files changed, 42 insertions(+), 43 deletions(-) create mode 100644 addons/base/data/core_currency_data.xml delete mode 100644 addons/base/data/core_currency_rate_data.xml diff --git a/addons/base/data/core_currency_data.xml b/addons/base/data/core_currency_data.xml new file mode 100644 index 00000000..cb77b4f8 --- /dev/null +++ b/addons/base/data/core_currency_data.xml @@ -0,0 +1,17 @@ + + + + + USD$True + EUR€True + GBP£True + IDRRpTrue + SGDS$True + JPY¥True + INR₹True + AUDA$True + + diff --git a/addons/base/data/core_currency_rate_data.xml b/addons/base/data/core_currency_rate_data.xml deleted file mode 100644 index 23b8c7ca..00000000 --- a/addons/base/data/core_currency_rate_data.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - USD$True - EUR€True - GBP£True - IDRRpTrue - SGDS$True - JPY¥True - INR₹True - AUDA$True - - - 1.02026-01-01True - 1.092026-01-01True - 1.272026-01-01True - 0.0000642026-01-01True - 0.742026-01-01True - 0.00672026-01-01True - 0.0122026-01-01True - 0.662026-01-01True - - diff --git a/addons/base/manifest.json b/addons/base/manifest.json index e06e8c85..2f466ade 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -7,7 +7,7 @@ "application": true, "data": [ "data/core_company_data.xml", - "data/core_currency_rate_data.xml", + "data/core_currency_data.xml", "data/uom_data.xml", "views/core_company_actions.xml", "views/core_users_actions.xml", diff --git a/addons/base/models/core_currency_rate.go b/addons/base/models/core_currency_rate.go index ee9c69ff..bda44e66 100644 --- a/addons/base/models/core_currency_rate.go +++ b/addons/base/models/core_currency_rate.go @@ -9,7 +9,8 @@ import ( // // Rates are global in this first iteration; verticals (e.g. accounting) can // layer company-scoped rates and rate providers on top of this primitive. -// Lookups resolve the newest DateFrom <= the conversion date (see +// No rates are seeded as core data — rows are created by verticals or rate +// providers. Lookups resolve the newest DateFrom <= the conversion date (see // sdk.ConvertCurrency). type CoreCurrencyRate struct { sdk.Model `sumeru:"model=core.currency.rate"` diff --git a/test/integration/currency_uom_convert_test.go b/test/integration/currency_uom_convert_test.go index aeb67de4..b53c2d1c 100644 --- a/test/integration/currency_uom_convert_test.go +++ b/test/integration/currency_uom_convert_test.go @@ -11,6 +11,7 @@ import ( "time" _ "sumeru/addons/base" + _ "sumeru/addons/i18n" "sumeru/core/modelreg" "sumeru/core/orm" "sumeru/core/sdk" @@ -25,7 +26,9 @@ func setupCurrencyUom(t *testing.T) context.Context { t.Skip("SUMERU_TEST_DSN not set") } orm.InitDB(dsn) - _ = modelreg.ActivateAll([]string{"base"}) + // i18n is loaded so ensureExtraIndexes can resolve sys.translation on an + // already-installed database (the index is created for any synced schema). + _ = modelreg.ActivateAll([]string{"base", "i18n"}) if err := orm.SyncRegistrySchemaForNames([]string{ "core.currency", "core.currency.rate", "uom.category", "uom.uom", }); err != nil { diff --git a/test/module/integration/base_sync/base_data_sync_test.go b/test/module/integration/base_sync/base_data_sync_test.go index 3ea0c94f..d263781e 100644 --- a/test/module/integration/base_sync/base_data_sync_test.go +++ b/test/module/integration/base_sync/base_data_sync_test.go @@ -49,9 +49,10 @@ func freshSyncTestDB(t *testing.T, dsn string) string { } // TestBaseAddonSyncToDBLoadsCurrencyUomData exercises Addon.SyncToDB with the -// base manifest XML: currencies, rates, uom categories/units, views, actions, -// and menus must all land in the database, with rate/uom records resolving -// cross-record refs and natural keys (regression test for the +// base manifest XML: the currency catalog (with no seeded rates — rate rows +// are business data created by verticals/providers), uom categories/units, +// views, actions, and menus must all land in the database, with uom records +// resolving cross-record refs and natural keys (regression test for the // core.currency.rate / uom.uom recordSyncSpecs entries). func TestBaseAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { dsn := os.Getenv("SUMERU_TEST_DSN") @@ -60,7 +61,16 @@ func TestBaseAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { } orm.InitDB(freshSyncTestDB(t, dsn)) _ = modelreg.ActivateAll([]string{"base"}) - if err := orm.SyncRegistrySchemaForNames(orm.InitialSetupModelNames); err != nil { + // Sync the kernel setup subset, skipping models owned by addons this + // binary does not load (e.g. sys.translation from the i18n addon) — + // base's data sync does not need them. + var kernelModels []string + for _, name := range orm.InitialSetupModelNames { + if _, ok := orm.Registry[name]; ok { + kernelModels = append(kernelModels, name) + } + } + if err := orm.SyncRegistrySchemaForNames(kernelModels); err != nil { t.Fatalf("kernel schema sync: %v", err) } if err := orm.SyncRegistrySchemaForModule("base"); err != nil { @@ -85,7 +95,7 @@ func TestBaseAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { want int }{ "currencies": {"core.currency", 8}, - "rates": {"core.currency.rate", 8}, + "rates": {"core.currency.rate", 0}, // no core seed: filled by verticals/providers "uom categories": {"uom.category", 5}, "uom units": {"uom.uom", 16}, } @@ -99,20 +109,13 @@ func TestBaseAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { } } - // Rate rows resolve the currency ref and carry the expected value. + // The currency catalog loads with its symbol data. eur, err := orm.SearchOne(ctx, "core.currency", map[string]interface{}{"name": "EUR"}) if err != nil { t.Fatalf("find EUR: %v", err) } - eurID, _ := orm.CoerceInt64(eur["id"]) - rateRow, err := orm.SearchOne(ctx, "core.currency.rate", map[string]interface{}{ - "currency_id": int(eurID), "date_from": "2026-01-01", - }) - if err != nil { - t.Fatalf("find EUR rate: %v", err) - } - if rate, ok := rateRow["rate"].(float64); !ok || math.Abs(rate-1.09) > 1e-9 { - t.Fatalf("EUR rate = %v, want 1.09", rateRow["rate"]) + if sym, ok := eur["symbol"].(string); !ok || sym != "€" { + t.Fatalf("EUR symbol = %v, want €", eur["symbol"]) } // UoM rows resolve the category ref and carry the expected factor. From cd0df14e40c518658940e7f4f441c88d66fe7b94 Mon Sep 17 00:00:00 2001 From: Indrajaya Date: Fri, 9 Oct 2026 00:46:10 +0000 Subject: [PATCH 04/13] Split currency and uom into dedicated core addons (SUM-PLAT-18) Keep base small, following the geo/i18n/im pattern: each core concern ships as its own addon on top of the kernel. - addons/currency: core.currency + core.currency.rate, catalog data, views, actions, and a Currencies menu section. - addons/uom: uom.category + uom.uom, seed data (5 categories, 16 units), views, actions, and a Units of Measure menu section. - base keeps only the kernel: currency/uom models, data files, views, actions, and menu entries move out of its manifest. - cmd/sumeru/zimports.go and model registrations regenerated; model names are unchanged, so sdk.ConvertCurrency/ConvertUom and the data-sync natural keys keep working untouched. - Tests: addon registration tests split per addon, integration tests load the new addons, and the SyncToDB regression test now syncs the install closure base -> currency -> uom. Co-Authored-By: Claude Code --- addons/base/data/uom_data.xml | 37 ---------- addons/base/manifest.json | 11 --- addons/base/models/zmodels.go | 4 -- addons/base/views/menus.xml | 17 ----- .../data/core_currency_data.xml | 0 addons/currency/init.go | 8 +++ addons/currency/manifest.json | 19 +++++ .../models/core_currency.go | 0 .../models/core_currency_rate.go | 0 addons/currency/models/zmodels.go | 12 ++++ .../views/core_currency_form_views.xml | 0 .../views/core_currency_list_views.xml | 0 .../views/core_currency_rate_form_views.xml | 0 .../views/core_currency_rate_list_views.xml | 0 .../views/currency_actions.xml} | 2 - addons/currency/views/menus.xml | 13 ++++ addons/uom/data/uom_data.xml | 37 ++++++++++ addons/uom/init.go | 8 +++ addons/uom/manifest.json | 19 +++++ addons/{base => uom}/models/uom.go | 0 addons/uom/models/zmodels.go | 12 ++++ addons/uom/views/menus.xml | 13 ++++ addons/uom/views/uom_actions.xml | 7 ++ .../views/uom_category_form_views.xml | 0 .../views/uom_category_list_views.xml | 0 .../views/uom_uom_form_views.xml | 0 .../views/uom_uom_list_views.xml | 0 cmd/sumeru/zimports.go | 2 + .../base/models/convert_money_field_test.go | 22 ++++++ test/addons/base/models/currency_uom_test.go | 72 ------------------- .../currency/models/currency_models_test.go | 46 ++++++++++++ test/addons/uom/models/uom_models_test.go | 35 +++++++++ test/integration/currency_uom_convert_test.go | 4 +- .../core_data_sync_test.go} | 59 ++++++++------- 34 files changed, 291 insertions(+), 168 deletions(-) delete mode 100644 addons/base/data/uom_data.xml rename addons/{base => currency}/data/core_currency_data.xml (100%) create mode 100644 addons/currency/init.go create mode 100644 addons/currency/manifest.json rename addons/{base => currency}/models/core_currency.go (100%) rename addons/{base => currency}/models/core_currency_rate.go (100%) create mode 100644 addons/currency/models/zmodels.go rename addons/{base => currency}/views/core_currency_form_views.xml (100%) rename addons/{base => currency}/views/core_currency_list_views.xml (100%) rename addons/{base => currency}/views/core_currency_rate_form_views.xml (100%) rename addons/{base => currency}/views/core_currency_rate_list_views.xml (100%) rename addons/{base/views/currency_uom_actions.xml => currency/views/currency_actions.xml} (58%) create mode 100644 addons/currency/views/menus.xml create mode 100644 addons/uom/data/uom_data.xml create mode 100644 addons/uom/init.go create mode 100644 addons/uom/manifest.json rename addons/{base => uom}/models/uom.go (100%) create mode 100644 addons/uom/models/zmodels.go create mode 100644 addons/uom/views/menus.xml create mode 100644 addons/uom/views/uom_actions.xml rename addons/{base => uom}/views/uom_category_form_views.xml (100%) rename addons/{base => uom}/views/uom_category_list_views.xml (100%) rename addons/{base => uom}/views/uom_uom_form_views.xml (100%) rename addons/{base => uom}/views/uom_uom_list_views.xml (100%) create mode 100644 test/addons/base/models/convert_money_field_test.go delete mode 100644 test/addons/base/models/currency_uom_test.go create mode 100644 test/addons/currency/models/currency_models_test.go create mode 100644 test/addons/uom/models/uom_models_test.go rename test/module/integration/{base_sync/base_data_sync_test.go => core_sync/core_data_sync_test.go} (70%) diff --git a/addons/base/data/uom_data.xml b/addons/base/data/uom_data.xml deleted file mode 100644 index 667fb533..00000000 --- a/addons/base/data/uom_data.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - Unit - Weight - Volume - Length - Time - - - Unit11True - Dozens121True - Hundreds1001True - - - kg10.001True - g0.0010.001True - mg0.0000010.001True - t10000.001True - - - l10.001True - ml0.0010.001True - m³10000.001True - - - m10.01True - cm0.010.01True - mm0.0010.01True - km10000.01True - - - Hours10.01True - Days80.01True - - diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 2f466ade..753acb68 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -7,8 +7,6 @@ "application": true, "data": [ "data/core_company_data.xml", - "data/core_currency_data.xml", - "data/uom_data.xml", "views/core_company_actions.xml", "views/core_users_actions.xml", "views/platform_actions.xml", @@ -36,15 +34,6 @@ "views/sys_rule_form_views.xml", "views/sys_field_access_list_views.xml", "views/sys_field_access_form_views.xml", - "views/currency_uom_actions.xml", - "views/core_currency_list_views.xml", - "views/core_currency_form_views.xml", - "views/core_currency_rate_list_views.xml", - "views/core_currency_rate_form_views.xml", - "views/uom_category_list_views.xml", - "views/uom_category_form_views.xml", - "views/uom_uom_list_views.xml", - "views/uom_uom_form_views.xml", "views/menus.xml" ] } diff --git a/addons/base/models/zmodels.go b/addons/base/models/zmodels.go index e534271b..6244ab1e 100644 --- a/addons/base/models/zmodels.go +++ b/addons/base/models/zmodels.go @@ -7,8 +7,6 @@ import "sumeru/core/sdk" func init() { sdk.MustRegister("base", &CoreCompany{}, - &CoreCurrency{}, - &CoreCurrencyRate{}, &CoreGroup{}, &CorePartner{}, &CoreUser{}, @@ -19,7 +17,5 @@ func init() { &ResConfigSettings{}, &SysAuthProvider{}, &SysFieldAccess{}, - &UomCategory{}, - &UomUom{}, ) } diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 7310bdf9..0724ab1a 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -63,23 +63,6 @@ - - - - - - - diff --git a/addons/base/data/core_currency_data.xml b/addons/currency/data/core_currency_data.xml similarity index 100% rename from addons/base/data/core_currency_data.xml rename to addons/currency/data/core_currency_data.xml diff --git a/addons/currency/init.go b/addons/currency/init.go new file mode 100644 index 00000000..a7043dee --- /dev/null +++ b/addons/currency/init.go @@ -0,0 +1,8 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package currency + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/currency/models" +) diff --git a/addons/currency/manifest.json b/addons/currency/manifest.json new file mode 100644 index 00000000..74d31aa0 --- /dev/null +++ b/addons/currency/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "currency", + "display_name": "Currencies", + "version": "1.0.0", + "author": "Sumeru", + "description": "Currency catalog and exchange rates as platform primitives.", + "depends": ["base"], + "auto_install": true, + "application": false, + "data": [ + "data/core_currency_data.xml", + "views/currency_actions.xml", + "views/core_currency_list_views.xml", + "views/core_currency_form_views.xml", + "views/core_currency_rate_list_views.xml", + "views/core_currency_rate_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/base/models/core_currency.go b/addons/currency/models/core_currency.go similarity index 100% rename from addons/base/models/core_currency.go rename to addons/currency/models/core_currency.go diff --git a/addons/base/models/core_currency_rate.go b/addons/currency/models/core_currency_rate.go similarity index 100% rename from addons/base/models/core_currency_rate.go rename to addons/currency/models/core_currency_rate.go diff --git a/addons/currency/models/zmodels.go b/addons/currency/models/zmodels.go new file mode 100644 index 00000000..d326c013 --- /dev/null +++ b/addons/currency/models/zmodels.go @@ -0,0 +1,12 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("currency", + &CoreCurrency{}, + &CoreCurrencyRate{}, + ) +} diff --git a/addons/base/views/core_currency_form_views.xml b/addons/currency/views/core_currency_form_views.xml similarity index 100% rename from addons/base/views/core_currency_form_views.xml rename to addons/currency/views/core_currency_form_views.xml diff --git a/addons/base/views/core_currency_list_views.xml b/addons/currency/views/core_currency_list_views.xml similarity index 100% rename from addons/base/views/core_currency_list_views.xml rename to addons/currency/views/core_currency_list_views.xml diff --git a/addons/base/views/core_currency_rate_form_views.xml b/addons/currency/views/core_currency_rate_form_views.xml similarity index 100% rename from addons/base/views/core_currency_rate_form_views.xml rename to addons/currency/views/core_currency_rate_form_views.xml diff --git a/addons/base/views/core_currency_rate_list_views.xml b/addons/currency/views/core_currency_rate_list_views.xml similarity index 100% rename from addons/base/views/core_currency_rate_list_views.xml rename to addons/currency/views/core_currency_rate_list_views.xml diff --git a/addons/base/views/currency_uom_actions.xml b/addons/currency/views/currency_actions.xml similarity index 58% rename from addons/base/views/currency_uom_actions.xml rename to addons/currency/views/currency_actions.xml index a3bc9996..a3350ea6 100644 --- a/addons/base/views/currency_uom_actions.xml +++ b/addons/currency/views/currency_actions.xml @@ -3,7 +3,5 @@ - - diff --git a/addons/currency/views/menus.xml b/addons/currency/views/menus.xml new file mode 100644 index 00000000..451bc263 --- /dev/null +++ b/addons/currency/views/menus.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/addons/uom/data/uom_data.xml b/addons/uom/data/uom_data.xml new file mode 100644 index 00000000..a9d75ba0 --- /dev/null +++ b/addons/uom/data/uom_data.xml @@ -0,0 +1,37 @@ + + + + + Unit + Weight + Volume + Length + Time + + + Unit11True + Dozens121True + Hundreds1001True + + + kg10.001True + g0.0010.001True + mg0.0000010.001True + t10000.001True + + + l10.001True + ml0.0010.001True + m³10000.001True + + + m10.01True + cm0.010.01True + mm0.0010.01True + km10000.01True + + + Hours10.01True + Days80.01True + + diff --git a/addons/uom/init.go b/addons/uom/init.go new file mode 100644 index 00000000..96c42470 --- /dev/null +++ b/addons/uom/init.go @@ -0,0 +1,8 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package uom + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/uom/models" +) diff --git a/addons/uom/manifest.json b/addons/uom/manifest.json new file mode 100644 index 00000000..f362e701 --- /dev/null +++ b/addons/uom/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "uom", + "display_name": "Units of Measure", + "version": "1.0.0", + "author": "Sumeru", + "description": "Units of measure with categories and conversion factors.", + "depends": ["base"], + "auto_install": true, + "application": false, + "data": [ + "data/uom_data.xml", + "views/uom_actions.xml", + "views/uom_category_list_views.xml", + "views/uom_category_form_views.xml", + "views/uom_uom_list_views.xml", + "views/uom_uom_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/base/models/uom.go b/addons/uom/models/uom.go similarity index 100% rename from addons/base/models/uom.go rename to addons/uom/models/uom.go diff --git a/addons/uom/models/zmodels.go b/addons/uom/models/zmodels.go new file mode 100644 index 00000000..dbc77091 --- /dev/null +++ b/addons/uom/models/zmodels.go @@ -0,0 +1,12 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("uom", + &UomCategory{}, + &UomUom{}, + ) +} diff --git a/addons/uom/views/menus.xml b/addons/uom/views/menus.xml new file mode 100644 index 00000000..f6bf70c1 --- /dev/null +++ b/addons/uom/views/menus.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/addons/uom/views/uom_actions.xml b/addons/uom/views/uom_actions.xml new file mode 100644 index 00000000..7a031140 --- /dev/null +++ b/addons/uom/views/uom_actions.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/addons/base/views/uom_category_form_views.xml b/addons/uom/views/uom_category_form_views.xml similarity index 100% rename from addons/base/views/uom_category_form_views.xml rename to addons/uom/views/uom_category_form_views.xml diff --git a/addons/base/views/uom_category_list_views.xml b/addons/uom/views/uom_category_list_views.xml similarity index 100% rename from addons/base/views/uom_category_list_views.xml rename to addons/uom/views/uom_category_list_views.xml diff --git a/addons/base/views/uom_uom_form_views.xml b/addons/uom/views/uom_uom_form_views.xml similarity index 100% rename from addons/base/views/uom_uom_form_views.xml rename to addons/uom/views/uom_uom_form_views.xml diff --git a/addons/base/views/uom_uom_list_views.xml b/addons/uom/views/uom_uom_list_views.xml similarity index 100% rename from addons/base/views/uom_uom_list_views.xml rename to addons/uom/views/uom_uom_list_views.xml diff --git a/cmd/sumeru/zimports.go b/cmd/sumeru/zimports.go index 7f66655b..c8e36ce3 100644 --- a/cmd/sumeru/zimports.go +++ b/cmd/sumeru/zimports.go @@ -5,6 +5,7 @@ package main import ( _ "sumeru/addons/base" _ "sumeru/addons/contacts" + _ "sumeru/addons/currency" _ "sumeru/addons/geo" _ "sumeru/addons/i18n" _ "sumeru/addons/im" @@ -13,4 +14,5 @@ import ( _ "sumeru/addons/platform" _ "sumeru/addons/audit" _ "sumeru/addons/automation" + _ "sumeru/addons/uom" ) diff --git a/test/addons/base/models/convert_money_field_test.go b/test/addons/base/models/convert_money_field_test.go new file mode 100644 index 00000000..d6610c3e --- /dev/null +++ b/test/addons/base/models/convert_money_field_test.go @@ -0,0 +1,22 @@ +package models_test + +import ( + "context" + "testing" + "time" + + _ "sumeru/addons/base" + "sumeru/core/modelreg" + "sumeru/core/sdk" +) + +// TestConvertMoneyFieldRejectsUntaggedField checks ConvertMoneyField against a +// real base model field that carries no currency= tag. +func TestConvertMoneyFieldRejectsUntaggedField(t *testing.T) { + _ = modelreg.ActivateAll([]string{"base"}) + _, err := sdk.ConvertMoneyField(context.Background(), "core.user", "name", + map[string]interface{}{"name": "x"}, 1, time.Time{}) + if err == nil { + t.Fatal("expected error: core.user.name is not currency-tagged") + } +} diff --git a/test/addons/base/models/currency_uom_test.go b/test/addons/base/models/currency_uom_test.go deleted file mode 100644 index f5b87293..00000000 --- a/test/addons/base/models/currency_uom_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package models_test - -import ( - "context" - "testing" - "time" - - _ "sumeru/addons/base" - "sumeru/core/modelreg" - "sumeru/core/orm" - "sumeru/core/sdk" -) - -// TestCurrencyUomModelsRegistered verifies the SUM-PLAT-18 platform models are -// registered with the expected field shapes. -func TestCurrencyUomModelsRegistered(t *testing.T) { - _ = modelreg.ActivateAll([]string{"base"}) - ctx := context.Background() - for _, name := range []string{"core.currency", "core.currency.rate", "uom.category", "uom.uom"} { - if _, ok := orm.Registry[name]; !ok { - t.Fatalf("model %s not registered", name) - } - } - - rate, _ := orm.Registry["core.currency.rate"] - fields := map[string]orm.FieldDefinition{} - for _, f := range rate.Fields() { - fields[f.Name] = f - } - if f, ok := fields["currency_id"]; !ok || f.Type != orm.Many2One || f.Relation != "core.currency" { - t.Fatalf("core.currency.rate.currency_id = %+v, want many2one core.currency", fields["currency_id"]) - } - if f, ok := fields["rate"]; !ok || f.Type != orm.Float64 { - t.Fatalf("core.currency.rate.rate = %+v, want float64", fields["rate"]) - } - if f, ok := fields["date_from"]; !ok || f.Type != orm.Date { - t.Fatalf("core.currency.rate.date_from = %+v, want date", fields["date_from"]) - } - - uom, _ := orm.Registry["uom.uom"] - fields = map[string]orm.FieldDefinition{} - for _, f := range uom.Fields() { - fields[f.Name] = f - } - if f, ok := fields["category_id"]; !ok || f.Type != orm.Many2One || f.Relation != "uom.category" { - t.Fatalf("uom.uom.category_id = %+v, want many2one uom.category", fields["category_id"]) - } - if f, ok := fields["factor"]; !ok || f.Type != orm.Float64 { - t.Fatalf("uom.uom.factor = %+v, want float64", fields["factor"]) - } - if f, ok := fields["rounding"]; !ok || f.Type != orm.Float64 { - t.Fatalf("uom.uom.rounding = %+v, want float64", fields["rounding"]) - } - - // The currency tag must keep flowing from struct tag to field definition. - currency, _ := orm.Registry["core.currency"] - hasName := false - for _, f := range currency.Fields() { - if f.Name == "name" && f.Type == orm.Char { - hasName = true - } - } - if !hasName { - t.Fatal("core.currency.name missing") - } - - // ConvertMoneyField against a real base model field without currency tag. - _, err := sdk.ConvertMoneyField(ctx, "core.user", "name", map[string]interface{}{"name": "x"}, 1, time.Time{}) - if err == nil { - t.Fatal("expected error: core.user.name is not currency-tagged") - } -} diff --git a/test/addons/currency/models/currency_models_test.go b/test/addons/currency/models/currency_models_test.go new file mode 100644 index 00000000..9131e758 --- /dev/null +++ b/test/addons/currency/models/currency_models_test.go @@ -0,0 +1,46 @@ +package models_test + +import ( + "testing" + + _ "sumeru/addons/currency" + "sumeru/core/modelreg" + "sumeru/core/orm" +) + +// TestCurrencyModelsRegistered verifies the SUM-PLAT-18 currency models are +// registered by the currency addon with the expected field shapes. +func TestCurrencyModelsRegistered(t *testing.T) { + _ = modelreg.ActivateAll([]string{"base", "currency"}) + for _, name := range []string{"core.currency", "core.currency.rate"} { + if _, ok := orm.Registry[name]; !ok { + t.Fatalf("model %s not registered", name) + } + } + + currency, _ := orm.Registry["core.currency"] + hasName := false + for _, f := range currency.Fields() { + if f.Name == "name" && f.Type == orm.Char { + hasName = true + } + } + if !hasName { + t.Fatal("core.currency.name missing") + } + + rate, _ := orm.Registry["core.currency.rate"] + fields := map[string]orm.FieldDefinition{} + for _, f := range rate.Fields() { + fields[f.Name] = f + } + if f, ok := fields["currency_id"]; !ok || f.Type != orm.Many2One || f.Relation != "core.currency" { + t.Fatalf("core.currency.rate.currency_id = %+v, want many2one core.currency", fields["currency_id"]) + } + if f, ok := fields["rate"]; !ok || f.Type != orm.Float64 { + t.Fatalf("core.currency.rate.rate = %+v, want float64", fields["rate"]) + } + if f, ok := fields["date_from"]; !ok || f.Type != orm.Date { + t.Fatalf("core.currency.rate.date_from = %+v, want date", fields["date_from"]) + } +} diff --git a/test/addons/uom/models/uom_models_test.go b/test/addons/uom/models/uom_models_test.go new file mode 100644 index 00000000..43076083 --- /dev/null +++ b/test/addons/uom/models/uom_models_test.go @@ -0,0 +1,35 @@ +package models_test + +import ( + "testing" + + _ "sumeru/addons/uom" + "sumeru/core/modelreg" + "sumeru/core/orm" +) + +// TestUomModelsRegistered verifies the SUM-PLAT-18 uom models are registered +// by the uom addon with the expected field shapes. +func TestUomModelsRegistered(t *testing.T) { + _ = modelreg.ActivateAll([]string{"base", "uom"}) + for _, name := range []string{"uom.category", "uom.uom"} { + if _, ok := orm.Registry[name]; !ok { + t.Fatalf("model %s not registered", name) + } + } + + uom, _ := orm.Registry["uom.uom"] + fields := map[string]orm.FieldDefinition{} + for _, f := range uom.Fields() { + fields[f.Name] = f + } + if f, ok := fields["category_id"]; !ok || f.Type != orm.Many2One || f.Relation != "uom.category" { + t.Fatalf("uom.uom.category_id = %+v, want many2one uom.category", fields["category_id"]) + } + if f, ok := fields["factor"]; !ok || f.Type != orm.Float64 { + t.Fatalf("uom.uom.factor = %+v, want float64", fields["factor"]) + } + if f, ok := fields["rounding"]; !ok || f.Type != orm.Float64 { + t.Fatalf("uom.uom.rounding = %+v, want float64", fields["rounding"]) + } +} diff --git a/test/integration/currency_uom_convert_test.go b/test/integration/currency_uom_convert_test.go index b53c2d1c..af1c5c6a 100644 --- a/test/integration/currency_uom_convert_test.go +++ b/test/integration/currency_uom_convert_test.go @@ -11,7 +11,9 @@ import ( "time" _ "sumeru/addons/base" + _ "sumeru/addons/currency" _ "sumeru/addons/i18n" + _ "sumeru/addons/uom" "sumeru/core/modelreg" "sumeru/core/orm" "sumeru/core/sdk" @@ -28,7 +30,7 @@ func setupCurrencyUom(t *testing.T) context.Context { orm.InitDB(dsn) // i18n is loaded so ensureExtraIndexes can resolve sys.translation on an // already-installed database (the index is created for any synced schema). - _ = modelreg.ActivateAll([]string{"base", "i18n"}) + _ = modelreg.ActivateAll([]string{"base", "i18n", "currency", "uom"}) if err := orm.SyncRegistrySchemaForNames([]string{ "core.currency", "core.currency.rate", "uom.category", "uom.uom", }); err != nil { diff --git a/test/module/integration/base_sync/base_data_sync_test.go b/test/module/integration/core_sync/core_data_sync_test.go similarity index 70% rename from test/module/integration/base_sync/base_data_sync_test.go rename to test/module/integration/core_sync/core_data_sync_test.go index d263781e..fd40a09b 100644 --- a/test/module/integration/base_sync/base_data_sync_test.go +++ b/test/module/integration/core_sync/core_data_sync_test.go @@ -1,10 +1,10 @@ //go:build integration -// Package base_sync integration-tests the module data sync path (Addon.SyncToDB) -// for the base addon in isolation from other integration test binaries, because -// activating kernel models changes access-check behavior for tests that search -// sys.* without a bypass context. -package base_sync_test +// Package core_sync integration-tests the module data sync path (Addon.SyncToDB) +// for the core addon install closure (base, currency, uom) in isolation from +// other integration test binaries, because activating kernel models changes +// access-check behavior for tests that search sys.* without a bypass context. +package core_sync_test import ( "context" @@ -16,6 +16,8 @@ import ( "testing" _ "sumeru/addons/base" + _ "sumeru/addons/currency" + _ "sumeru/addons/uom" "sumeru/core/modelreg" "sumeru/core/module" "sumeru/core/orm" @@ -48,22 +50,23 @@ func freshSyncTestDB(t *testing.T, dsn string) string { return re.ReplaceAllString(dsn, "dbname="+syncDBName) } -// TestBaseAddonSyncToDBLoadsCurrencyUomData exercises Addon.SyncToDB with the -// base manifest XML: the currency catalog (with no seeded rates — rate rows -// are business data created by verticals/providers), uom categories/units, -// views, actions, and menus must all land in the database, with uom records -// resolving cross-record refs and natural keys (regression test for the -// core.currency.rate / uom.uom recordSyncSpecs entries). -func TestBaseAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { +// TestCoreAddonSyncToDBLoadsCurrencyUomData exercises Addon.SyncToDB across the +// core addon install closure (base, currency, uom): the currency catalog (with +// no seeded rates — rate rows are business data created by verticals/providers), +// uom categories/units, views, actions, and menus must all land in the +// database, with uom records resolving cross-record refs and natural keys +// (regression test for the core.currency.rate / uom.uom recordSyncSpecs +// entries). +func TestCoreAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { dsn := os.Getenv("SUMERU_TEST_DSN") if dsn == "" { t.Skip("SUMERU_TEST_DSN not set") } orm.InitDB(freshSyncTestDB(t, dsn)) - _ = modelreg.ActivateAll([]string{"base"}) + _ = modelreg.ActivateAll([]string{"base", "currency", "uom"}) // Sync the kernel setup subset, skipping models owned by addons this // binary does not load (e.g. sys.translation from the i18n addon) — - // base's data sync does not need them. + // the core data sync does not need them. var kernelModels []string for _, name := range orm.InitialSetupModelNames { if _, ok := orm.Registry[name]; ok { @@ -73,21 +76,24 @@ func TestBaseAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { if err := orm.SyncRegistrySchemaForNames(kernelModels); err != nil { t.Fatalf("kernel schema sync: %v", err) } - if err := orm.SyncRegistrySchemaForModule("base"); err != nil { - t.Fatalf("base schema sync: %v", err) - } root := harness.RepoRoot(t) discovered, err := module.DiscoverAddonRoots([]string{filepath.Join(root, "addons")}) if err != nil { t.Fatalf("discover addons: %v", err) } - addon, ok := discovered["base"] - if !ok { - t.Fatal("base addon not discovered") - } ctx := orm.ContextWithBypass(context.Background(), true) - if err := addon.SyncToDB(ctx); err != nil { - t.Fatalf("base SyncToDB: %v", err) + // Install-closure order: schema sync then data sync per module. + for _, name := range []string{"base", "currency", "uom"} { + addon, ok := discovered[name] + if !ok { + t.Fatalf("addon %s not discovered", name) + } + if err := orm.SyncRegistrySchemaForModule(name); err != nil { + t.Fatalf("%s schema sync: %v", name, err) + } + if err := addon.SyncToDB(ctx); err != nil { + t.Fatalf("%s SyncToDB: %v", name, err) + } } counts := map[string]struct { @@ -140,7 +146,12 @@ func TestBaseAddonSyncToDBLoadsCurrencyUomData(t *testing.T) { t.Fatalf("sys.action.window %q missing: %v", name, err) } } - menus := []string{"Currencies & Units", "Currencies", "Currency Rates", "UoM Categories", "Units of Measure"} + // Section menus live in their own module (currency / uom) under the base + // Settings root. + menus := []string{ + "Currencies", "All Currencies", "Currency Rates", + "Units of Measure", "UoM Categories", "UoM Units", + } for _, menu := range menus { if _, err := orm.SearchOne(ctx, "sys.menu", map[string]interface{}{"name": menu}); err != nil { t.Fatalf("menu %q missing: %v", menu, err) From 86b78ce75de4d61044c7439d3b0e029adfda9366 Mon Sep 17 00:00:00 2001 From: Indrajaya Date: Fri, 9 Oct 2026 06:30:16 +0000 Subject: [PATCH 05/13] ci: bump Go toolchain to 1.26.9 to fix govulncheck The govulncheck job went red on every PR after GO-2026-6599/6600 were published: go1.26.6's standard library carries 12 reachable vulnerabilities (html/template, net/http) fixed in the go1.26.9 patch release. Bump the go directive and all CI go-version pins; patch releases keep API compatibility, so no code changes are needed. Verified locally with go1.26.9: govulncheck reports 0 vulnerabilities, build/vet/lint clean, test suite green, coverage 42.5% (min 42). Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 12 ++++++------ go.mod | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acf9a66e..fb93969b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: Build all packages @@ -46,7 +46,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: Module static tests @@ -84,7 +84,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: Security bypass lint @@ -115,7 +115,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: govulncheck @@ -165,7 +165,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: "1.26.6" + go-version: "1.26.9" cache: true - name: Regenerate zimports.go @@ -206,7 +206,7 @@ jobs: # - uses: actions/setup-go@v7 # with: - # go-version: "1.26.6" + # go-version: "1.26.9" # cache: true # - name: Bootstrap kernel schema diff --git a/go.mod b/go.mod index 2d9ebd37..6efd0409 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module sumeru -go 1.26.6 +go 1.26.9 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 From 2579dfebb0b48c1d827efd32b349f1672222d76d Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Mon, 12 Oct 2026 23:51:45 +0530 Subject: [PATCH 06/13] feat(automation): complete SUM-PLAT-19 triggers, safety, and server-action UI Wire record.created/updated/deleted to sys.server.action with unlink-safe domains, depth cap 5, 60s cooldown via sys.server.action.log, and https-only webhooks. Run actions as the event actor without bypass; reject sensitive write: fields. Add activity: server actions and form fields for model, event, domain, and code. --- addons/automation/actions.go | 115 +++++++++--- addons/automation/hooks.go | 31 +++- addons/automation/init.go | 1 + addons/automation/manifest.json | 2 +- addons/automation/models/sys_server_action.go | 2 +- .../models/sys_server_action_log.go | 15 ++ addons/automation/models/zmodels.go | 1 + addons/automation/safety.go | 175 ++++++++++++++++++ addons/automation/security/sys.access.csv | 1 + addons/automation/testexports.go | 4 + .../views/sys_server_action_form_views.xml | 3 +- addons/automation/webhook.go | 2 +- test/addons/automation/safety_test.go | 77 ++++++++ test/addons/automation/webhook_test.go | 1 + 14 files changed, 401 insertions(+), 29 deletions(-) create mode 100644 addons/automation/models/sys_server_action_log.go create mode 100644 addons/automation/safety.go create mode 100644 test/addons/automation/safety_test.go diff --git a/addons/automation/actions.go b/addons/automation/actions.go index 4dec9229..499039b1 100644 --- a/addons/automation/actions.go +++ b/addons/automation/actions.go @@ -3,6 +3,7 @@ package automation import ( "context" "encoding/json" + "fmt" "strings" "sumeru/core/applog" @@ -10,34 +11,59 @@ import ( "sumeru/core/orm" ) -// executeServerAction runs a matching sys.server.action row for an event. -// Code conventions (declarative, no script engine): -// - publish: — publish a follow-up event (payload copied from trigger) -// - write: — ORM write on payload model/id -func executeServerAction(ctx context.Context, row map[string]interface{}, ev event.Event) error { +func actionApplies(ctx context.Context, row map[string]interface{}, ev event.Event) bool { actionModel := strings.TrimSpace(orm.AsString(row["model"])) - if actionModel != "" { - evModel := strings.TrimSpace(orm.AsString(ev.Payload["model"])) - if evModel != "" && actionModel != evModel { - return nil + evModel := strings.TrimSpace(orm.AsString(ev.Payload["model"])) + if ev.Name == "cron.tick" { + if actionModel != "" || strings.TrimSpace(orm.AsString(row["trigger_domain"])) != "" { + return false } } - if domRaw := strings.TrimSpace(orm.AsString(row["trigger_domain"])); domRaw != "" { - modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"])) - resID, ok := orm.CoerceInt64(ev.Payload["id"]) - if modelName == "" || !ok || resID <= 0 { - return nil - } - var domain [][]interface{} - if err := json.Unmarshal([]byte(domRaw), &domain); err != nil { - return nil - } - matches, err := orm.SearchCount(ctx, modelName, append(domain, []interface{}{"id", "=", resID})) - if err != nil || matches == 0 { - return nil + if actionModel != "" && evModel != "" && actionModel != evModel { + return false + } + domRaw := strings.TrimSpace(orm.AsString(row["trigger_domain"])) + if ev.Name == orm.EventRecordDeleted { + if domRaw == "" { + return true } + actionID, _ := orm.CoerceInt64(row["id"]) + resID, ok := orm.CoerceInt64(ev.Payload["id"]) + return ok && deleteActionMatched(evModel, int(resID), actionID) + } + if domRaw == "" { + return true + } + resID, ok := orm.CoerceInt64(ev.Payload["id"]) + if evModel == "" || !ok || resID <= 0 { + return false + } + var domain [][]interface{} + if err := json.Unmarshal([]byte(domRaw), &domain); err != nil { + applog.Warn(ctx, applog.Event{ + Message: "server action domain JSON invalid", + Component: "automation", + Operation: "server_action", + Status: "failed", + Context: map[string]interface{}{"action": row["name"]}, + Err: err, + }) + return false } + matches, err := orm.SearchCount(ctx, evModel, append(domain, []interface{}{"id", "=", resID})) + return err == nil && matches > 0 +} +// executeServerAction runs a matching sys.server.action row for an event. +// Code conventions (declarative, no script engine): +// - publish: — publish a follow-up event (payload copied from trigger) +// - write: — ORM write on payload model/id +// - webhook: +// - activity:| +func executeServerAction(ctx context.Context, row map[string]interface{}, ev event.Event) error { + if !actionApplies(ctx, row, ev) { + return nil + } code := strings.TrimSpace(orm.AsString(row["code"])) if code == "" { return nil @@ -45,6 +71,9 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve switch { case strings.HasPrefix(code, "publish:"): + if depthOf(ctx) >= automationDepthCap { + return fmt.Errorf("automation depth exceeded") + } name := strings.TrimSpace(strings.TrimPrefix(code, "publish:")) if name == "" { return nil @@ -53,7 +82,7 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve if payload == nil { payload = map[string]interface{}{} } - errs := event.Publish(ctx, event.Event{Name: name, Actor: ev.Actor, Payload: payload}) + errs := event.Publish(withDepth(ctx, depthOf(ctx)+1), event.Event{Name: name, Actor: ev.Actor, Payload: payload}) if len(errs) > 0 { return errs[0] } @@ -81,6 +110,9 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve if modelName == "" || !ok || resID <= 0 { return nil } + if err := rejectUnsafeWrite(modelName, vals); err != nil { + return err + } return orm.UpdateRecordByID(ctx, modelName, int(resID), vals) case strings.HasPrefix(code, "webhook:"): @@ -90,6 +122,9 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve } return dispatchWebhook(ctx, url, ev) + case strings.HasPrefix(code, "activity:"): + return createActivity(ctx, strings.TrimPrefix(code, "activity:"), ev) + default: applog.DebugMsg(ctx, "module", "automation", "server action code not executed (unknown prefix)", @@ -97,3 +132,37 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve } return nil } + +func createActivity(ctx context.Context, spec string, ev event.Event) error { + spec = strings.TrimSpace(spec) + xmlid, summary, _ := strings.Cut(spec, "|") + xmlid = strings.TrimSpace(xmlid) + summary = strings.TrimSpace(summary) + modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"])) + resID, ok := orm.CoerceInt64(ev.Payload["id"]) + if xmlid == "" || modelName == "" || !ok || resID <= 0 { + return fmt.Errorf("activity action requires type, model, and id") + } + if _, registered := orm.Registry["mail.activity"]; !registered { + return fmt.Errorf("mail.activity is not installed") + } + typeID, typeModel, err := orm.ResolveXmlId(ctx, xmlid) + if err != nil || typeModel != "mail.activity.type" || typeID <= 0 { + return fmt.Errorf("unknown activity type %q", xmlid) + } + if summary == "" { + summary = xmlid + } + vals := map[string]interface{}{ + "model": modelName, + "res_id": int(resID), + "activity_type_id": typeID, + "summary": summary, + "state": "planned", + } + if ev.Actor > 0 { + vals["user_id"] = ev.Actor + } + _, err = orm.Create(ctx, orm.Registry["mail.activity"], vals) + return err +} diff --git a/addons/automation/hooks.go b/addons/automation/hooks.go index 0ea52744..a7ff48db 100644 --- a/addons/automation/hooks.go +++ b/addons/automation/hooks.go @@ -2,6 +2,7 @@ package automation import ( "context" + "strings" "sumeru/core/applog" "sumeru/core/event" @@ -15,7 +16,9 @@ func init() { func registerHooks() { event.Subscribe("record.created", runServerActionsForEvent) event.Subscribe("record.updated", runServerActionsForEvent) + event.Subscribe("record.deleted", runServerActionsForEvent) event.Subscribe("cron.tick", runServerActionsForEvent) + orm.RegisterBeforeDelete(matchBeforeDelete) } func runServerActionsForEvent(ctx context.Context, ev event.Event) error { @@ -25,6 +28,10 @@ func runServerActionsForEvent(ctx context.Context, ev event.Event) error { if _, ok := orm.Registry["sys.server.action"]; !ok { return nil } + modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"])) + if automationSkippedModel(modelName) { + return nil + } return orm.WithElevated(ctx, "automation.server_action", func(bypass context.Context) error { installed, err := orm.InstalledModuleNames(bypass) if err != nil || !orm.ShouldMaterializeModel("sys.server.action", installed) { @@ -37,9 +44,29 @@ func runServerActionsForEvent(ctx context.Context, ev event.Event) error { if err != nil { return err } + runCtx := actorContext(ctx, ev.Actor) for _, row := range rows { - if err := executeServerAction(ctx, row, ev); err != nil { - applog.Warn(ctx, applog.Event{ + if !actionApplies(runCtx, row, ev) { + continue + } + actionID, _ := orm.CoerceInt64(row["id"]) + resID, _ := orm.CoerceInt64(ev.Payload["id"]) + claimed, err := claimRun(runCtx, actionID, ev, modelName, resID) + if err != nil { + applog.Warn(runCtx, applog.Event{ + Message: "server action log failed", + Component: "automation", + Operation: "server_action", + Status: "failed", + Err: err, + }) + continue + } + if !claimed { + continue + } + if err := executeServerAction(runCtx, row, ev); err != nil { + applog.Warn(runCtx, applog.Event{ Message: "server action failed", Component: "automation", Operation: "server_action", diff --git a/addons/automation/init.go b/addons/automation/init.go index 29e5d501..175d309d 100644 --- a/addons/automation/init.go +++ b/addons/automation/init.go @@ -6,5 +6,6 @@ import ( _ "sumeru/addons/base" _ "sumeru/addons/im" _ "sumeru/addons/audit" + _ "sumeru/addons/mail" _ "sumeru/addons/automation/models" ) diff --git a/addons/automation/manifest.json b/addons/automation/manifest.json index 3025556e..81bd9738 100644 --- a/addons/automation/manifest.json +++ b/addons/automation/manifest.json @@ -4,7 +4,7 @@ "version": "1.0.0", "author": "Sumeru", "description": "Scheduled jobs, workflow transitions, server actions, and event bindings.", - "depends": ["base", "audit", "im"], + "depends": ["base", "audit", "im", "mail"], "application": false, "data": [ "data/platform_bulk_job_cron.xml", diff --git a/addons/automation/models/sys_server_action.go b/addons/automation/models/sys_server_action.go index 08e089b1..388bf4d1 100644 --- a/addons/automation/models/sys_server_action.go +++ b/addons/automation/models/sys_server_action.go @@ -12,6 +12,6 @@ type SysServerAction struct { EventName sdk.String `sumeru:"index,string=On Event"` TriggerOn sdk.String `sumeru:"string=Trigger On,default=event"` TriggerDomain sdk.Text `sumeru:"string=Trigger Domain"` - Code sdk.Text `sumeru:"string=Code / Notes"` + Code sdk.Text `sumeru:"string=Code,help=publish:event write:json webhook:https://host/path activity:module.xmlid|summary"` Active sdk.Boolean `sumeru:"string=Active,default=true"` } diff --git a/addons/automation/models/sys_server_action_log.go b/addons/automation/models/sys_server_action_log.go new file mode 100644 index 00000000..7379d4bb --- /dev/null +++ b/addons/automation/models/sys_server_action_log.go @@ -0,0 +1,15 @@ +package models + +import "sumeru/core/sdk" + +type SysServerActionLog struct { + sdk.Model `sumeru:"model=sys.server.action.log"` + + ActionID sdk.Many2One[SysServerAction] `sumeru:"required,index,string=Action"` + EventName sdk.String `sumeru:"required,index,string=Event"` + ResModel sdk.String `sumeru:"index,column=model,string=Model"` + ResID sdk.Integer `sumeru:"index,string=Record"` + RanAt sdk.DateTime `sumeru:"required,string=Ran At"` + DedupeKey sdk.String `sumeru:"required,unique,string=Dedupe Key"` + Detail sdk.Text `sumeru:"string=Detail"` +} diff --git a/addons/automation/models/zmodels.go b/addons/automation/models/zmodels.go index 91d28d57..32889eed 100644 --- a/addons/automation/models/zmodels.go +++ b/addons/automation/models/zmodels.go @@ -8,6 +8,7 @@ func init() { sdk.MustRegister("automation", &SysCron{}, &SysServerAction{}, + &SysServerActionLog{}, &SysWorkflowTransition{}, ) } diff --git a/addons/automation/safety.go b/addons/automation/safety.go new file mode 100644 index 00000000..27800c45 --- /dev/null +++ b/addons/automation/safety.go @@ -0,0 +1,175 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "sumeru/core/applog" + "sumeru/core/event" + "sumeru/core/orm" + "sumeru/core/security" +) + +const automationDepthCap = 5 + +type depthKey struct{} + +func withDepth(ctx context.Context, n int) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, depthKey{}, n) +} + +func depthOf(ctx context.Context) int { + if ctx == nil { + return 0 + } + n, _ := ctx.Value(depthKey{}).(int) + return n +} + +func actorContext(ctx context.Context, actor int) context.Context { + out := orm.ContextWithUID(context.Background(), actor) + if cid := orm.CompanyIDFromContext(ctx); cid > 0 { + out = orm.ContextWithCompanyID(out, cid) + } + return withDepth(out, depthOf(ctx)) +} + +func automationSkippedModel(model string) bool { + switch strings.TrimSpace(model) { + case "mail.message", "sys.audit", "sys.server.action", "sys.server.action.log", "sys.outbox.event": + return true + default: + return false + } +} + +var deleteMatches sync.Map + +func deleteMatchKey(model string, id int) string { + return model + "|" + fmt.Sprint(id) +} + +func noteDeleteMatch(model string, id int, actionIDs map[int64]struct{}) { + deleteMatches.Store(deleteMatchKey(model, id), actionIDs) +} + +func deleteActionMatched(model string, id int, actionID int64) bool { + raw, ok := deleteMatches.Load(deleteMatchKey(model, id)) + if !ok { + return false + } + set, _ := raw.(map[int64]struct{}) + _, hit := set[actionID] + return hit +} + +func matchBeforeDelete(ctx context.Context, model string, id int, before map[string]interface{}) { + if orm.DB == nil || id <= 0 || automationSkippedModel(model) { + return + } + if _, ok := orm.Registry["sys.server.action"]; !ok { + return + } + _ = orm.WithElevated(ctx, "automation.delete_match", func(bypass context.Context) error { + rows, err := orm.Search(bypass, "sys.server.action", [][]interface{}{ + {"event_name", "=", orm.EventRecordDeleted}, + {"active", "=", true}, + }) + if err != nil { + return err + } + matched := map[int64]struct{}{} + for _, row := range rows { + actionModel := strings.TrimSpace(orm.AsString(row["model"])) + if actionModel != "" && actionModel != model { + continue + } + domRaw := strings.TrimSpace(orm.AsString(row["trigger_domain"])) + if domRaw == "" { + continue + } + var parsed [][]interface{} + if err := json.Unmarshal([]byte(domRaw), &parsed); err != nil { + applog.Warn(bypass, applog.Event{ + Message: "server action domain JSON invalid", + Component: "automation", + Operation: "server_action", + Status: "failed", + Context: map[string]interface{}{"action": row["name"]}, + Err: err, + }) + continue + } + if !orm.RecordMatchesDomain(model, before, parsed) { + continue + } + actionID, ok := orm.CoerceInt64(row["id"]) + if ok { + matched[actionID] = struct{}{} + } + } + noteDeleteMatch(model, id, matched) + return nil + }) +} + +func claimRun(ctx context.Context, actionID int64, ev event.Event, model string, resID int64) (bool, error) { + if orm.DB == nil || actionID <= 0 { + return true, nil + } + if _, ok := orm.Registry["sys.server.action.log"]; !ok { + return true, nil + } + bucket := time.Now().UTC().Unix() / 60 + key := fmt.Sprintf("%d|%s|%s|%d|%d", actionID, ev.Name, model, resID, bucket) + claimed := false + err := orm.WithElevated(ctx, "automation.action_log", func(bypass context.Context) error { + inst := orm.Registry["sys.server.action.log"] + _, err := orm.Create(bypass, inst, map[string]interface{}{ + "action_id": actionID, + "event_name": ev.Name, + "model": model, + "res_id": resID, + "ran_at": time.Now().UTC().Format(time.RFC3339), + "dedupe_key": key, + "detail": "run", + }) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate") || strings.Contains(strings.ToLower(err.Error()), "unique") { + return nil + } + return err + } + claimed = true + return nil + }) + return claimed, err +} + +func rejectUnsafeWrite(modelName string, vals map[string]interface{}) error { + inst := orm.RegistryModel(modelName) + if inst == nil { + return fmt.Errorf("unknown model %q", modelName) + } + known := map[string]struct{}{} + for _, field := range inst.Fields() { + known[field.Name] = struct{}{} + } + policies := security.FieldRegistry[modelName] + for key := range vals { + if _, ok := known[key]; !ok { + return fmt.Errorf("unknown field %q on %s", key, modelName) + } + if pol, ok := policies[key]; ok && (pol.ReadRedact || pol.WriteDenyDirect || pol.WriteDenyUnlessSys) { + return fmt.Errorf("field %q cannot be set by a server action", key) + } + } + return nil +} diff --git a/addons/automation/security/sys.access.csv b/addons/automation/security/sys.access.csv index e1416f11..f8c2206d 100644 --- a/addons/automation/security/sys.access.csv +++ b/addons/automation/security/sys.access.csv @@ -5,3 +5,4 @@ access_sys_workflow_user,access_sys_workflow_user,sys.workflow.transition,base.g access_sys_workflow_manager,access_sys_workflow_manager,sys.workflow.transition,automation.group_automation_manager,1,1,1,1 access_sys_server_action_user,access_sys_server_action_user,sys.server.action,base.group_user,1,0,0,0 access_sys_server_action_manager,access_sys_server_action_manager,sys.server.action,automation.group_automation_manager,1,1,1,1 +access_sys_server_action_log_manager,access_sys_server_action_log_manager,sys.server.action.log,automation.group_automation_manager,1,0,0,0 diff --git a/addons/automation/testexports.go b/addons/automation/testexports.go index 1b2e1068..246e883d 100644 --- a/addons/automation/testexports.go +++ b/addons/automation/testexports.go @@ -14,6 +14,10 @@ func ExecuteServerActionForTest(ctx context.Context, row map[string]interface{}, return executeServerAction(ctx, row, ev) } +func WithAutomationDepthForTest(ctx context.Context, n int) context.Context { + return withDepth(ctx, n) +} + func RunServerActionsForEventForTest(ctx context.Context, ev event.Event) error { return runServerActionsForEvent(ctx, ev) } diff --git a/addons/automation/views/sys_server_action_form_views.xml b/addons/automation/views/sys_server_action_form_views.xml index e30fa7a0..fcf65384 100644 --- a/addons/automation/views/sys_server_action_form_views.xml +++ b/addons/automation/views/sys_server_action_form_views.xml @@ -7,7 +7,8 @@ - + + diff --git a/addons/automation/webhook.go b/addons/automation/webhook.go index 3e7555f0..ca472c53 100644 --- a/addons/automation/webhook.go +++ b/addons/automation/webhook.go @@ -146,7 +146,7 @@ func validateWebhookURL(raw string) error { return fmt.Errorf("invalid webhook url: %w", err) } scheme := strings.ToLower(u.Scheme) - if scheme != "https" && scheme != "http" { + if scheme != "https" { return fmt.Errorf("webhook scheme %q not allowed", u.Scheme) } host := strings.TrimSpace(u.Hostname()) diff --git a/test/addons/automation/safety_test.go b/test/addons/automation/safety_test.go new file mode 100644 index 00000000..def149a4 --- /dev/null +++ b/test/addons/automation/safety_test.go @@ -0,0 +1,77 @@ +package automation_test + +import ( + "context" + "testing" + + "sumeru/addons/automation" + "sumeru/core/event" + "sumeru/core/orm" + "sumeru/core/security" +) + +func TestExecuteServerActionDepthCap(t *testing.T) { + ctx := automation.WithAutomationDepthForTest(context.Background(), 5) + row := map[string]interface{}{"name": "Deep", "code": "publish:test.depth"} + ev := event.Event{Name: "record.created", Payload: map[string]interface{}{"model": "crm.lead", "id": int64(1)}} + if err := automation.ExecuteServerActionForTest(ctx, row, ev); err == nil { + t.Fatal("expected depth cap to stop publish") + } +} + +func TestExecuteServerActionInvalidDomainFailsClosed(t *testing.T) { + var got string + event.Subscribe("test.bad.domain", func(_ context.Context, ev event.Event) error { + got = ev.Name + return nil + }) + defer event.Clear() + row := map[string]interface{}{ + "name": "Bad", + "code": "publish:test.bad.domain", + "trigger_domain": "{", + } + ev := event.Event{Name: "record.created", Payload: map[string]interface{}{"model": "crm.lead", "id": int64(1)}} + if err := automation.ExecuteServerActionForTest(context.Background(), row, ev); err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("invalid domain published %q", got) + } +} + +func TestExecuteServerActionRejectsSensitiveWrite(t *testing.T) { + const model = "test.server.action.write" + orm.RegisterStubModelForTest(t, model, []orm.FieldDefinition{ + {Name: "name", Type: orm.Char}, + {Name: "password", Type: orm.Char}, + }) + prev := security.FieldRegistry[model] + security.FieldRegistry[model] = map[string]security.FieldPolicy{ + "password": {WriteDenyDirect: true}, + } + t.Cleanup(func() { + if prev == nil { + delete(security.FieldRegistry, model) + return + } + security.FieldRegistry[model] = prev + }) + row := map[string]interface{}{"name": "Write", "code": `write:{"password":"secret","name":"ok"}`} + ev := event.Event{Name: "record.updated", Payload: map[string]interface{}{"model": model, "id": int64(1)}} + if err := automation.ExecuteServerActionForTest(context.Background(), row, ev); err == nil { + t.Fatal("expected sensitive write to be rejected") + } + unknown := map[string]interface{}{"name": "Write", "code": `write:{"missing":1}`} + if err := automation.ExecuteServerActionForTest(context.Background(), unknown, ev); err == nil { + t.Fatal("expected unknown field to be rejected") + } +} + +func TestExecuteServerActionActivityRequiresType(t *testing.T) { + row := map[string]interface{}{"name": "Act", "code": "activity:mail.activity_type_todo|Call"} + ev := event.Event{Name: "record.created", Payload: map[string]interface{}{"model": "crm.lead", "id": int64(1)}} + if err := automation.ExecuteServerActionForTest(context.Background(), row, ev); err == nil { + t.Fatal("expected missing activity type to fail") + } +} diff --git a/test/addons/automation/webhook_test.go b/test/addons/automation/webhook_test.go index a97f9294..3c577215 100644 --- a/test/addons/automation/webhook_test.go +++ b/test/addons/automation/webhook_test.go @@ -21,6 +21,7 @@ func TestValidateWebhookURL(t *testing.T) { {"https://10.0.0.5/hook", true}, {"http://100.64.0.1/hook", true}, {"https://8.8.8.8/hook", false}, + {"http://8.8.8.8/hook", true}, } for _, tc := range cases { err := automation.ValidateWebhookURLForTest(tc.url) From 03eab20ac4724fe838c7080d7fe4d1bcbe70179f Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Mon, 12 Oct 2026 23:52:14 +0530 Subject: [PATCH 07/13] feat(platform): add properties JSONB field and SUM-PLAT-24 form widget Introduce sys.property.definition seeding, domain support for field.key, ORM properties handling, and SWC PropertiesField registration with tests. --- addons/base/models/sys_property_definition.go | 17 + addons/base/models/zmodels.go | 1 + core/modelmeta/types.go | 1 + core/modelreg/register.go | 1 + core/orm/crud_domain.go | 10 + core/orm/crud_mutate.go | 13 + core/orm/crud_prepare.go | 3 + core/orm/crud_search.go | 36 +- core/orm/model.go | 3 +- core/orm/properties.go | 325 ++++++++++++++++++ core/orm/registry.go | 2 +- core/orm/setup_seed.go | 1 + core/orm/values_prepare.go | 9 + core/sdk/fields.go | 1 + core/swc/src/widgets/PropertiesField.ts | 148 ++++++++ core/swc/src/widgets/registry.ts | 3 + core/swc/tests/views/properties-field.test.ts | 48 +++ test/core/orm/properties_test.go | 96 ++++++ 18 files changed, 715 insertions(+), 3 deletions(-) create mode 100644 addons/base/models/sys_property_definition.go create mode 100644 core/orm/properties.go create mode 100644 core/swc/src/widgets/PropertiesField.ts create mode 100644 core/swc/tests/views/properties-field.test.ts create mode 100644 test/core/orm/properties_test.go diff --git a/addons/base/models/sys_property_definition.go b/addons/base/models/sys_property_definition.go new file mode 100644 index 00000000..b63ccd14 --- /dev/null +++ b/addons/base/models/sys_property_definition.go @@ -0,0 +1,17 @@ +package models + +import "sumeru/core/sdk" + +type SysPropertyDefinition struct { + sdk.Model `sumeru:"model=sys.property.definition"` + + ResModel sdk.String `sumeru:"required,index,column=model,string=Model"` + FieldName sdk.String `sumeru:"required,index,string=Field"` + Key sdk.String `sumeru:"required,index,string=Key"` + Label sdk.String `sumeru:"string=Label"` + ValueType sdk.String `sumeru:"required,string=Type,selection=char:Text,integer:Integer,boolean:Boolean,date:Date,selection:Selection,many2one:Many2one"` + Selection sdk.Text `sumeru:"string=Selection"` + Relation sdk.String `sumeru:"string=Relation"` + Required sdk.Boolean `sumeru:"string=Required,default=false"` + Sequence sdk.Integer `sumeru:"string=Sequence,default=10"` +} diff --git a/addons/base/models/zmodels.go b/addons/base/models/zmodels.go index 6244ab1e..e119a815 100644 --- a/addons/base/models/zmodels.go +++ b/addons/base/models/zmodels.go @@ -17,5 +17,6 @@ func init() { &ResConfigSettings{}, &SysAuthProvider{}, &SysFieldAccess{}, + &SysPropertyDefinition{}, ) } diff --git a/core/modelmeta/types.go b/core/modelmeta/types.go index 3cdf7d3e..d8f38514 100644 --- a/core/modelmeta/types.go +++ b/core/modelmeta/types.go @@ -28,6 +28,7 @@ type ( DateTime struct{} Duration struct{} Json struct{} + Properties struct{} Binary struct{} Image struct{} Reference struct{} diff --git a/core/modelreg/register.go b/core/modelreg/register.go index abfb3187..8af93878 100644 --- a/core/modelreg/register.go +++ b/core/modelreg/register.go @@ -272,6 +272,7 @@ var markerTypeByName = map[string]markerType{ "DateTime": {orm.DateTime, "datetime"}, "Duration": {orm.Numeric, "duration"}, "Json": {orm.Json, "json"}, + "Properties": {orm.Properties, "properties"}, "Binary": {orm.Text, "binary"}, "Image": {orm.Text, "image"}, "Reference": {orm.Char, "reference"}, diff --git a/core/orm/crud_domain.go b/core/orm/crud_domain.go index c4d0201c..b07b60f4 100644 --- a/core/orm/crud_domain.go +++ b/core/orm/crud_domain.go @@ -33,6 +33,16 @@ func buildSearchWhereClause(modelName string, domain [][]interface{}) (string, [ return "", nil, fmt.Errorf("domain field name") } op := strings.TrimSpace(strings.ToLower(fmt.Sprint(clause[1]))) + if strings.Contains(field, ".") { + frag, fragArgs, next, err := propertiesDomainClause(modelName, field, op, clause[2], placeholderIndex) + if err != nil { + return "", nil, err + } + parts = append(parts, frag) + args = append(args, fragArgs...) + placeholderIndex = next + continue + } if frag, handled, err := buildFalsyDomainClause(modelName, field, op, clause[2]); handled { if err != nil { return "", nil, err diff --git a/core/orm/crud_mutate.go b/core/orm/crud_mutate.go index ca3e0cd8..aefb438f 100644 --- a/core/orm/crud_mutate.go +++ b/core/orm/crud_mutate.go @@ -118,6 +118,13 @@ func executeUpdateMutation(ctx context.Context, modelName string, domain [][]int if len(locked) == 0 { return fmt.Errorf("access denied or record not found") } + var previous map[string]interface{} + if len(locked) == 1 { + previous = locked[0] + } + if err := MergePropertiesWrite(ctx, modelName, previous, prepared); err != nil { + return err + } if len(locked) == 1 { merged := mergeRecordMap(locked[0], prepared) if err := MergeStoredComputes(ctx, modelName, merged); err != nil { @@ -236,6 +243,12 @@ func executeDeleteMutation(ctx context.Context, modelName string, domain [][]int } result.RowsAffected = n if shouldEmitSideEffects(ctx, modelName) { + if beforeDeleteHook != nil { + for _, recordMap := range locked { + recordID, _ := CoerceInt64(recordMap["id"]) + beforeDeleteHook(ctx, modelName, int(recordID), recordMap) + } + } for _, recordMap := range locked { recordID, _ := CoerceInt64(recordMap["id"]) sideRows = append(sideRows, sideEffectRow{ diff --git a/core/orm/crud_prepare.go b/core/orm/crud_prepare.go index 531e6a3e..a8f4ddd5 100644 --- a/core/orm/crud_prepare.go +++ b/core/orm/crud_prepare.go @@ -30,6 +30,9 @@ func prepareCreateWrite(ctx context.Context, model Model, values map[string]inte if err := applySpecialDefaults(ctx, fieldDefs, prepared); err != nil { return nil, 0, err } + if err := MergePropertiesWrite(ctx, model.ModelName(), nil, prepared); err != nil { + return nil, 0, err + } working := mergeRecordMap(map[string]interface{}{}, prepared) if err := MergeStoredComputes(ctx, model.ModelName(), working); err != nil { return nil, 0, err diff --git a/core/orm/crud_search.go b/core/orm/crud_search.go index 1a007d22..7578474f 100644 --- a/core/orm/crud_search.go +++ b/core/orm/crud_search.go @@ -6,7 +6,40 @@ import ( "time" ) -// SearchInterceptor allows addons to intercept and modify search domains. +// SearchResultFilter drops or rewrites rows after a search. Addons use it for parent-record checks. +type SearchResultFilter func(ctx context.Context, model string, rows []map[string]interface{}) []map[string]interface{} + +// SearchResultFilters is applied at the end of every search. +var SearchResultFilters []SearchResultFilter + +// RegisterSearchResultFilter adds a post-search filter. +func RegisterSearchResultFilter(fn SearchResultFilter) { + SearchResultFilters = append(SearchResultFilters, fn) +} + +func applySearchResultFilters(ctx context.Context, model string, rows []map[string]interface{}) []map[string]interface{} { + for _, fn := range SearchResultFilters { + if fn == nil { + continue + } + rows = fn(ctx, model, rows) + } + if rows == nil { + return []map[string]interface{}{} + } + return rows +} + +// BeforeDeleteFunc sees each row before it is removed. The payload of the public event stays model and id. +type BeforeDeleteFunc func(ctx context.Context, model string, id int, before map[string]interface{}) + +var beforeDeleteHook BeforeDeleteFunc + +// RegisterBeforeDelete sets the single pre-delete hook. Automation uses it for domain checks. +func RegisterBeforeDelete(fn BeforeDeleteFunc) { + beforeDeleteHook = fn +} + type SearchInterceptor func(ctx context.Context, model string, domain [][]interface{}) ([][]interface{}, error) var ( @@ -60,6 +93,7 @@ func execSearchQuery(ctx context.Context, modelName string, domain [][]interface return nil, err } enrichRecordsForRead(ctx, uid, modelName, results) + results = applySearchResultFilters(ctx, modelName, results) return results, nil } diff --git a/core/orm/model.go b/core/orm/model.go index 7b4b57e7..88b9ab4d 100644 --- a/core/orm/model.go +++ b/core/orm/model.go @@ -16,7 +16,8 @@ const ( Many2One FieldType = "many2one" Many2Many FieldType = "many2many" One2Many FieldType = "one2many" - Json FieldType = "json" + Json FieldType = "json" + Properties FieldType = "properties" ) type FieldDefinition struct { diff --git a/core/orm/properties.go b/core/orm/properties.go new file mode 100644 index 00000000..56e433f6 --- /dev/null +++ b/core/orm/properties.go @@ -0,0 +1,325 @@ +package orm + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "regexp" + "strings" +) + +const ( + maxPropertyBytes = 64 * 1024 + maxPropertyKeys = 50 +) + +var propertyKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// PropertyDef is one schema-lite key stored on sys.property.definition. +type PropertyDef struct { + Key string + ValueType string + Selection string + Relation string + Required bool +} + +// PropertyDefLoader resolves definitions for a model field. Tests replace it. +var PropertyDefLoader func(model, field string) (map[string]PropertyDef, error) + +func loadPropertyDefs(ctx context.Context, modelName, fieldName string) (map[string]PropertyDef, error) { + if PropertyDefLoader != nil { + return PropertyDefLoader(modelName, fieldName) + } + out := map[string]PropertyDef{} + if DB == nil { + return out, nil + } + if _, ok := Registry["sys.property.definition"]; !ok { + return out, nil + } + table, err := QuotedTableName("sys.property.definition") + if err != nil { + return nil, err + } + rows, err := DB.QueryContext(ctx, `SELECT key, value_type, selection, relation, required FROM `+table+` WHERE model = $1 AND field_name = $2`, modelName, fieldName) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var key string + var valueType, selection, relation sql.NullString + var required sql.NullBool + if err := rows.Scan(&key, &valueType, &selection, &relation, &required); err != nil { + return nil, err + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + out[key] = PropertyDef{ + Key: key, + ValueType: strings.TrimSpace(valueType.String), + Selection: selection.String, + Relation: relation.String, + Required: required.Bool, + } + } + return out, rows.Err() +} + +func coerceProperties(v interface{}) (string, error) { + if v == nil { + return "", nil + } + var raw []byte + switch typed := v.(type) { + case string: + s := strings.TrimSpace(typed) + if s == "" || s == "null" { + return "", nil + } + raw = []byte(s) + case []byte: + raw = typed + default: + encoded, err := json.Marshal(typed) + if err != nil { + return "", fmt.Errorf("properties: %w", err) + } + raw = encoded + } + if len(raw) > maxPropertyBytes { + return "", fmt.Errorf("properties payload exceeds %d bytes", maxPropertyBytes) + } + if string(raw) == "null" { + return "", nil + } + var obj map[string]interface{} + if err := json.Unmarshal(raw, &obj); err != nil { + return "", fmt.Errorf("properties must be a JSON object") + } + if obj == nil { + return "{}", nil + } + if len(obj) > maxPropertyKeys { + return "", fmt.Errorf("properties exceed %d keys", maxPropertyKeys) + } + for key := range obj { + if err := validatePropertyKey(key); err != nil { + return "", err + } + } + canonical, err := json.Marshal(obj) + if err != nil { + return "", err + } + if len(canonical) > maxPropertyBytes { + return "", fmt.Errorf("properties payload exceeds %d bytes", maxPropertyBytes) + } + return string(canonical), nil +} + +func validatePropertyKey(key string) error { + switch key { + case "__proto__", "constructor", "prototype": + return fmt.Errorf("properties key %q is not allowed", key) + } + if !propertyKeyPattern.MatchString(key) { + return fmt.Errorf("properties key %q is not allowed", key) + } + return nil +} + +func parsePropertiesObject(raw interface{}) (map[string]interface{}, error) { + canonical, err := coerceProperties(raw) + if err != nil { + return nil, err + } + if canonical == "" { + return map[string]interface{}{}, nil + } + var obj map[string]interface{} + if err := json.Unmarshal([]byte(canonical), &obj); err != nil { + return nil, err + } + if obj == nil { + obj = map[string]interface{}{} + } + return obj, nil +} + +// MergePropertiesWrite checks defined keys and keeps values whose definition was removed. +func MergePropertiesWrite(ctx context.Context, modelName string, before, prepared map[string]interface{}) error { + inst := RegistryModel(modelName) + if inst == nil { + return nil + } + for _, field := range inst.Fields() { + if field.Type != Properties { + continue + } + incomingRaw, ok := prepared[field.Name] + if !ok { + continue + } + incoming, err := parsePropertiesObject(incomingRaw) + if err != nil { + return fmt.Errorf("field %s: %w", field.Name, err) + } + previous := map[string]interface{}{} + if before != nil { + previous, err = parsePropertiesObject(before[field.Name]) + if err != nil { + previous = map[string]interface{}{} + } + } + defs, err := loadPropertyDefs(ctx, modelName, field.Name) + if err != nil { + return err + } + merged := map[string]interface{}{} + for key, value := range previous { + if _, defined := defs[key]; !defined { + merged[key] = value + } + } + for key, value := range incoming { + def, defined := defs[key] + if !defined { + return fmt.Errorf("field %s: unknown property %q", field.Name, key) + } + checked, err := checkPropertyValue(def, value) + if err != nil { + return fmt.Errorf("field %s: %w", field.Name, err) + } + merged[key] = checked + } + for _, def := range defs { + if !def.Required { + continue + } + if _, ok := merged[def.Key]; !ok { + return fmt.Errorf("field %s: required property %q missing", field.Name, def.Key) + } + } + if len(merged) > maxPropertyKeys { + return fmt.Errorf("field %s: properties exceed %d keys", field.Name, maxPropertyKeys) + } + encoded, err := json.Marshal(merged) + if err != nil { + return err + } + prepared[field.Name] = string(encoded) + } + return nil +} + +func checkPropertyValue(def PropertyDef, value interface{}) (interface{}, error) { + switch def.ValueType { + case "char", "": + return strings.TrimSpace(AsString(value)), nil + case "integer", "many2one": + n, ok := CoerceInt64(value) + if !ok { + return nil, fmt.Errorf("property %q must be an integer", def.Key) + } + if def.ValueType == "many2one" && n <= 0 { + return nil, fmt.Errorf("property %q must be a positive integer", def.Key) + } + return n, nil + case "boolean": + return AsBool(value), nil + case "date": + s := strings.TrimSpace(AsString(value)) + if len(s) < 10 { + return nil, fmt.Errorf("property %q must be a date", def.Key) + } + return s[:10], nil + case "selection": + s := strings.TrimSpace(AsString(value)) + allowed := map[string]struct{}{} + for _, part := range strings.Split(def.Selection, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key := part + if i := strings.IndexByte(part, ':'); i >= 0 { + key = part[:i] + } + allowed[key] = struct{}{} + } + if _, ok := allowed[s]; !ok { + return nil, fmt.Errorf("property %q value %q is not in the selection", def.Key, s) + } + return s, nil + default: + return nil, fmt.Errorf("property %q has unknown type %q", def.Key, def.ValueType) + } +} + +func propertiesDomainClause(modelName, fieldExpr, op string, value interface{}, placeholder int) (string, []interface{}, int, error) { + field, key, ok := strings.Cut(fieldExpr, ".") + if !ok || field == "" || key == "" { + return "", nil, placeholder, fmt.Errorf("invalid property domain %q", fieldExpr) + } + if err := validatePropertyKey(key); err != nil { + return "", nil, placeholder, err + } + fd := FieldDef(modelName, field) + if fd == nil || fd.Type != Properties { + return "", nil, placeholder, fmt.Errorf("field %q on %s is not properties", field, modelName) + } + defs, err := loadPropertyDefs(context.Background(), modelName, field) + if err != nil { + return "", nil, placeholder, err + } + def, defined := defs[key] + if !defined { + return "", nil, placeholder, fmt.Errorf("undefined property key %q", key) + } + col, err := QuotedColumnForModel(modelName, field) + if err != nil { + return "", nil, placeholder, err + } + expr := fmt.Sprintf("(%s->>'%s')", col, key) + op = strings.TrimSpace(strings.ToLower(op)) + switch def.ValueType { + case "integer", "many2one": + switch op { + case "=", "!=", ">", ">=", "<", "<=": + cmp := strings.ToUpper(op) + if op == "!=" { + return fmt.Sprintf("(%s IS DISTINCT FROM $%d::text)", expr, placeholder), []interface{}{fmt.Sprint(value)}, placeholder + 1, nil + } + if op == "=" { + return fmt.Sprintf("%s = $%d", expr, placeholder), []interface{}{fmt.Sprint(value)}, placeholder + 1, nil + } + safe := fmt.Sprintf("(CASE WHEN %s ~ '^-?[0-9]+$' THEN %s::bigint END) %s $%d", expr, expr, cmp, placeholder) + n, ok := CoerceInt64(value) + if !ok { + return "", nil, placeholder, fmt.Errorf("property %q comparison needs an integer", key) + } + return safe, []interface{}{n}, placeholder + 1, nil + default: + return "", nil, placeholder, fmt.Errorf("operator %q is not valid for property %q", op, key) + } + default: + switch op { + case "=": + return fmt.Sprintf("%s = $%d", expr, placeholder), []interface{}{value}, placeholder + 1, nil + case "!=": + return fmt.Sprintf("(%s IS DISTINCT FROM $%d)", expr, placeholder), []interface{}{value}, placeholder + 1, nil + case "ilike", "like": + if def.ValueType != "char" && def.ValueType != "" { + return "", nil, placeholder, fmt.Errorf("operator %q is not valid for property %q", op, key) + } + return fmt.Sprintf("%s ILIKE $%d", expr, placeholder), []interface{}{value}, placeholder + 1, nil + default: + return "", nil, placeholder, fmt.Errorf("operator %q is not valid for property %q", op, key) + } + } +} diff --git a/core/orm/registry.go b/core/orm/registry.go index f395e264..1f84d31d 100644 --- a/core/orm/registry.go +++ b/core/orm/registry.go @@ -99,7 +99,7 @@ func ColumnTypeSQL(f FieldDefinition) (string, bool) { return "TIMESTAMPTZ", true case Selection: return "VARCHAR(50)", true - case Json: + case Json, Properties: return "JSONB", true case Many2One: return "BIGINT", true diff --git a/core/orm/setup_seed.go b/core/orm/setup_seed.go index 06cccf85..658d9b30 100644 --- a/core/orm/setup_seed.go +++ b/core/orm/setup_seed.go @@ -268,6 +268,7 @@ func ensureBootstrapACLs(ctx context.Context, adminGID, userGID int) { {"core.group", "access_core_group_user_read"}, {"sys.access", "access_sys_access_user_read"}, {"sys.rule", "access_sys_rule_user_read"}, + {"sys.property.definition", "access_sys_property_definition_user"}, } { _, _ = Upsert(ctx, RegistryModel("sys.access"), map[string]interface{}{ "name": pair.name, diff --git a/core/orm/values_prepare.go b/core/orm/values_prepare.go index 07d81889..90e4b56a 100644 --- a/core/orm/values_prepare.go +++ b/core/orm/values_prepare.go @@ -215,6 +215,15 @@ func coerceFieldValue(fieldDef FieldDefinition, v interface{}) (interface{}, err return nil, newFieldValidationError(fieldDef, "") } return AsString(v), nil + case Properties: + canonical, err := coerceProperties(v) + if err != nil { + return nil, err + } + if canonical == "" && fieldDef.Required { + return nil, newFieldValidationError(fieldDef, "") + } + return canonical, nil case Date, DateTime: s := strings.TrimSpace(AsString(v)) if s == "" { diff --git a/core/sdk/fields.go b/core/sdk/fields.go index 62c7737e..949a5455 100644 --- a/core/sdk/fields.go +++ b/core/sdk/fields.go @@ -27,6 +27,7 @@ type ( DateTime = modelmeta.DateTime Duration = modelmeta.Duration Json = modelmeta.Json + Properties = modelmeta.Properties Binary = modelmeta.Binary Image = modelmeta.Image Reference = modelmeta.Reference diff --git a/core/swc/src/widgets/PropertiesField.ts b/core/swc/src/widgets/PropertiesField.ts new file mode 100644 index 00000000..a9e0a1d8 --- /dev/null +++ b/core/swc/src/widgets/PropertiesField.ts @@ -0,0 +1,148 @@ +import { SwcComponent } from "../runtime/component.js"; +import { html, type TemplateResult } from "../template/html.js"; +import { AsyncFieldController } from "./field-async.js"; +import { isFieldReadonly } from "../model/modifiers.js"; +import { fieldInputId, renderFieldShell, shellOptions } from "./field-shell.js"; +import type { FieldWidgetProps } from "./field-props.js"; + +interface PropertyDef { + key: string; + label: string; + value_type: string; + selection?: string; +} + +function asObject(raw: unknown): Record { + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + return { ...(raw as Record) }; + } + if (typeof raw === "string" && raw.trim()) { + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return {}; + } + } + return {}; +} + +/** One input per property definition. An empty definition is an empty state. */ +export class PropertiesField extends SwcComponent { + private defs: PropertyDef[] = []; + private loaded = false; + private readonly asyncCtrl = new AsyncFieldController(this); + + override setup(): void { + void this.loadDefs(); + } + + override onWillUnmount(): void { + this.asyncCtrl.cancel(); + } + + private async loadDefs(): Promise { + const gen = this.asyncCtrl.begin(); + const schema = this.props.field.options?.schema; + if (schema) { + try { + const parsed = JSON.parse(schema) as PropertyDef[]; + this.defs = Array.isArray(parsed) ? parsed.filter((row) => row.key) : []; + } catch { + this.defs = []; + } + this.loaded = true; + this.asyncCtrl.commitIfCurrent(gen); + return; + } + const model = this.props.modelName ?? ""; + if (!model) { + this.defs = []; + this.loaded = true; + this.asyncCtrl.commitIfCurrent(gen); + return; + } + const rows = await this.env.services.rpc.searchRead( + "sys.property.definition", + [ + ["model", "=", model], + ["field_name", "=", this.props.field.name], + ], + ["key", "label", "value_type", "selection", "sequence"], + 50, + ); + rows.sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0)); + this.defs = rows.map((row) => ({ + key: String(row.key ?? ""), + label: String(row.label ?? row.key ?? ""), + value_type: String(row.value_type ?? "char"), + selection: row.selection ? String(row.selection) : "", + })); + this.loaded = true; + this.asyncCtrl.commitIfCurrent(gen); + } + + private setKey(key: string, value: unknown): void { + const next = asObject(this.props.record.get(this.props.field.name)); + next[key] = value; + this.props.record.set(this.props.field.name, next); + } + + private input(def: PropertyDef, value: unknown, readonly: boolean): TemplateResult { + const id = `${fieldInputId(this.props.field)}-${def.key}`; + if (readonly) { + return html`${value == null ? "" : String(value)}`; + } + if (def.value_type === "boolean") { + return html` { + const target = event.target as HTMLInputElement; + this.setKey(def.key, target.checked); + }} />`; + } + if (def.value_type === "integer" || def.value_type === "many2one") { + return html` { + const target = event.target as HTMLInputElement; + this.setKey(def.key, target.value === "" ? null : Number(target.value)); + }} />`; + } + if (def.value_type === "selection") { + const options = (def.selection ?? "").split(",").map((part) => part.trim()).filter(Boolean); + return html``; + } + return html` { + this.setKey(def.key, (event.target as HTMLInputElement).value); + }} />`; + } + + override template() { + const { field, record, readonly } = this.props; + const locked = isFieldReadonly(field, record, readonly); + const values = asObject(record.get(field.name)); + let body: TemplateResult; + if (!this.loaded) { + body = html`

Loading properties

`; + } else if (this.defs.length === 0) { + body = html`

No properties defined

`; + } else { + body = html`
+ ${this.defs.map( + (def) => html``, + )} +
`; + } + return renderFieldShell(field, body, shellOptions(record, { labelFor: false })); + } +} diff --git a/core/swc/src/widgets/registry.ts b/core/swc/src/widgets/registry.ts index 6d7f7dc5..259295ba 100644 --- a/core/swc/src/widgets/registry.ts +++ b/core/swc/src/widgets/registry.ts @@ -25,6 +25,7 @@ import { ProgressField, HandleField, } from "./extra-fields.js"; +import { PropertiesField } from "./PropertiesField.js"; const FIELD_CONSTRUCTORS = { default: DefaultField, @@ -36,6 +37,7 @@ const FIELD_CONSTRUCTORS = { date: DateField, datetime: DateTimeField, json: TextareaField, + properties: PropertiesField, many2one: Many2OneField, one2many: One2ManyField, many2many: Many2ManyTagsField, @@ -83,6 +85,7 @@ const TYPE_MAP = { integer: "integer", float: "float", numeric: "numeric", + properties: "properties", } satisfies Record; export function resolveFieldWidget(field: SwcArchField): string { diff --git a/core/swc/tests/views/properties-field.test.ts b/core/swc/tests/views/properties-field.test.ts new file mode 100644 index 00000000..344301c5 --- /dev/null +++ b/core/swc/tests/views/properties-field.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { SwcRecord } from "../../src/model/record.js"; +import { PropertiesField } from "../../src/widgets/PropertiesField.js"; +import type { SwcArchField } from "../../src/types/workspace.js"; +import type { SwcEnv } from "../../src/runtime/env.js"; + +function env(): SwcEnv { + return { bootstrap: {} as never, services: {} as never } as SwcEnv; +} + +describe("PropertiesField", () => { + it("renders one input per definition and not a JSON editor", () => { + const field: SwcArchField = { + name: "props", + string: "Properties", + type: "properties", + widget: "properties", + options: { + schema: JSON.stringify([ + { key: "color", label: "Color", value_type: "char" }, + { key: "qty", label: "Qty", value_type: "integer" }, + ]), + }, + }; + const record = new SwcRecord("demo.model", 1, { props: { color: "red", qty: 2 } }); + const comp = new PropertiesField({ field, record, readonly: false }, env()); + comp.callSetup(); + const el = comp.render(); + expect(el.textContent).toContain("Color"); + expect(el.textContent).toContain("Qty"); + expect(el.querySelector("textarea")).toBeNull(); + expect(el.querySelectorAll("input").length).toBe(2); + }); + + it("shows an empty state when no definitions exist", () => { + const field: SwcArchField = { + name: "props", + string: "Properties", + type: "properties", + widget: "properties", + options: { schema: "[]" }, + }; + const record = new SwcRecord("demo.model", 1, { props: {} }); + const comp = new PropertiesField({ field, record, readonly: false }, env()); + comp.callSetup(); + expect(comp.render().textContent).toContain("No properties defined"); + }); +}); diff --git a/test/core/orm/properties_test.go b/test/core/orm/properties_test.go new file mode 100644 index 00000000..36fd14f2 --- /dev/null +++ b/test/core/orm/properties_test.go @@ -0,0 +1,96 @@ +package orm_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "sumeru/core/orm" +) + +func TestPropertiesRejectsUnsafeKeysAndTypes(t *testing.T) { + const model = "test.properties.record" + orm.RegisterStubModelForTest(t, model, []orm.FieldDefinition{ + {Name: "props", Type: orm.Properties}, + }) + orm.PropertyDefLoader = func(modelName, field string) (map[string]orm.PropertyDef, error) { + return map[string]orm.PropertyDef{ + "label": {Key: "label", ValueType: "char", Required: true}, + "qty": {Key: "qty", ValueType: "integer"}, + "choice": {Key: "choice", ValueType: "selection", Selection: "a:A,b:B"}, + "owner": {Key: "owner", ValueType: "many2one"}, + }, nil + } + t.Cleanup(func() { orm.PropertyDefLoader = nil }) + + if _, err := orm.CoerceFieldValueForTest(orm.FieldDefinition{Name: "props", Type: orm.Properties}, nil); err != nil { + t.Fatal(err) + } + if _, err := orm.CoerceFieldValueForTest(orm.FieldDefinition{Name: "props", Type: orm.Properties}, "{}"); err != nil { + t.Fatal(err) + } + if _, err := orm.CoerceFieldValueForTest(orm.FieldDefinition{Name: "props", Type: orm.Properties}, `{"__proto__":1}`); err == nil { + t.Fatal("expected forbidden key to fail") + } + + prepared := map[string]interface{}{"props": map[string]interface{}{"label": "ok", "qty": 2, "choice": "a", "owner": 3}} + if err := orm.MergePropertiesWrite(context.Background(), model, nil, prepared); err != nil { + t.Fatal(err) + } + kept := map[string]interface{}{"props": map[string]interface{}{"label": "ok"}} + before := map[string]interface{}{"props": `{"retired":"stay"}`} + if err := orm.MergePropertiesWrite(context.Background(), model, before, kept); err != nil { + t.Fatal(err) + } + if !strings.Contains(fmt.Sprint(kept["props"]), "retired") { + t.Fatalf("removed definition key was dropped: %v", kept["props"]) + } + bad := map[string]interface{}{"props": map[string]interface{}{"label": "ok", "extra": 1}} + if err := orm.MergePropertiesWrite(context.Background(), model, nil, bad); err == nil { + t.Fatal("expected unknown key to fail") + } + missing := map[string]interface{}{"props": map[string]interface{}{"qty": 1}} + if err := orm.MergePropertiesWrite(context.Background(), model, nil, missing); err == nil { + t.Fatal("expected required key to fail") + } + choice := map[string]interface{}{"props": map[string]interface{}{"label": "ok", "choice": "nope"}} + if err := orm.MergePropertiesWrite(context.Background(), model, nil, choice); err == nil { + t.Fatal("expected selection to fail") + } + owner := map[string]interface{}{"props": map[string]interface{}{"label": "ok", "owner": 0}} + if err := orm.MergePropertiesWrite(context.Background(), model, nil, owner); err == nil { + t.Fatal("expected many2one to fail") + } +} + +func TestPropertiesDomainKeyIsLiteral(t *testing.T) { + const model = "test.properties.domain" + orm.RegisterStubModelForTest(t, model, []orm.FieldDefinition{ + {Name: "props", Type: orm.Properties, Column: "props"}, + }) + orm.PropertyDefLoader = func(modelName, field string) (map[string]orm.PropertyDef, error) { + return map[string]orm.PropertyDef{ + "label": {Key: "label", ValueType: "char"}, + "qty": {Key: "qty", ValueType: "integer"}, + }, nil + } + t.Cleanup(func() { orm.PropertyDefLoader = nil }) + + where, args, err := orm.BuildSearchWhereClauseForTest(model, [][]interface{}{{"props.label", "ilike", "red"}}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(where, "->>'label'") || strings.Contains(where, "red") { + t.Fatalf("where %q args %v", where, args) + } + if _, _, err := orm.BuildSearchWhereClauseForTest(model, [][]interface{}{{"props.missing", "=", "x"}}); err == nil { + t.Fatal("expected undefined key to fail") + } + if _, _, err := orm.BuildSearchWhereClauseForTest(model, [][]interface{}{{"props.qty", "ilike", "1"}}); err == nil { + t.Fatal("expected ilike on integer to fail") + } + if _, _, err := orm.BuildSearchWhereClauseForTest(model, [][]interface{}{{"props.qty';drop", "=", "1"}}); err == nil { + t.Fatal("expected unsafe key to fail") + } +} From 5f67cf363c24f89cde7054205899a211253c2e10 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Mon, 12 Oct 2026 23:52:57 +0530 Subject: [PATCH 08/13] feat(mail): restore activity models and SUM-PLAT-21 activity board UX Add mail addon for mail.activity and mail.message, board grouping (overdue/today/later), done/snooze actions, parent deep links, shell Activities link, and calendar arch date_start behavior. Outbound SMTP unchanged; no inbound routing. --- addons/mail/actions.go | 92 +++++++++ addons/mail/data/mail_activity_type_data.xml | 17 ++ addons/mail/init.go | 9 + addons/mail/manifest.json | 23 +++ addons/mail/models/mail_activity.go | 17 ++ addons/mail/models/mail_activity_type.go | 11 + addons/mail/models/mail_message.go | 19 ++ addons/mail/models/zmodels.go | 13 ++ addons/mail/models/zrefs.go | 17 ++ addons/mail/parent_filter.go | 34 ++++ addons/mail/security/security.xml | 19 ++ addons/mail/security/sys.access.csv | 7 + addons/mail/views/actions.xml | 8 + .../views/mail_activity_activity_views.xml | 17 ++ .../mail/views/mail_activity_form_views.xml | 26 +++ .../mail/views/mail_activity_list_views.xml | 13 ++ .../views/mail_activity_type_form_views.xml | 14 ++ .../views/mail_activity_type_list_views.xml | 9 + addons/mail/views/mail_message_form_views.xml | 22 ++ addons/mail/views/mail_message_list_views.xml | 11 + addons/mail/views/menus.xml | 9 + cmd/sumeru/zimports.go | 1 + core/engine/swcmeta/arch.go | 13 +- core/engine/swcmeta/types.go | 109 +++++----- core/engine/templates/shell/base.html | 16 +- core/swc/src/shell/activity-context.ts | 18 ++ core/swc/src/shell/shell-chrome.ts | 15 +- core/swc/src/views/activity/ActivityView.ts | 188 ++++++++++++++++-- core/swc/src/views/calendar/CalendarView.ts | 13 +- core/swc/tests/views/activity-board.test.ts | 71 +++++++ core/swc/tests/views/view-shells.test.ts | 51 +++-- 31 files changed, 794 insertions(+), 108 deletions(-) create mode 100644 addons/mail/actions.go create mode 100644 addons/mail/data/mail_activity_type_data.xml create mode 100644 addons/mail/init.go create mode 100644 addons/mail/manifest.json create mode 100644 addons/mail/models/mail_activity.go create mode 100644 addons/mail/models/mail_activity_type.go create mode 100644 addons/mail/models/mail_message.go create mode 100644 addons/mail/models/zmodels.go create mode 100644 addons/mail/models/zrefs.go create mode 100644 addons/mail/parent_filter.go create mode 100644 addons/mail/security/security.xml create mode 100644 addons/mail/security/sys.access.csv create mode 100644 addons/mail/views/actions.xml create mode 100644 addons/mail/views/mail_activity_activity_views.xml create mode 100644 addons/mail/views/mail_activity_form_views.xml create mode 100644 addons/mail/views/mail_activity_list_views.xml create mode 100644 addons/mail/views/mail_activity_type_form_views.xml create mode 100644 addons/mail/views/mail_activity_type_list_views.xml create mode 100644 addons/mail/views/mail_message_form_views.xml create mode 100644 addons/mail/views/mail_message_list_views.xml create mode 100644 addons/mail/views/menus.xml create mode 100644 core/swc/tests/views/activity-board.test.ts diff --git a/addons/mail/actions.go b/addons/mail/actions.go new file mode 100644 index 00000000..b6e39f76 --- /dev/null +++ b/addons/mail/actions.go @@ -0,0 +1,92 @@ +package mail + +import ( + "context" + "fmt" + "strings" + "time" + + "sumeru/core/orm" +) + +func init() { + orm.RegisterObjectAction("mail.activity", "action_done", actionDone) + orm.RegisterObjectAction("mail.activity", "action_snooze", actionSnooze) + orm.RegisterSearchResultFilter(filterParentReadable) +} + +func actionDone(ctx context.Context, model string, id int, _ map[string]string) (string, error) { + if model != "mail.activity" || id <= 0 { + return "", fmt.Errorf("record not found") + } + rec, err := orm.SearchOne(ctx, "mail.activity", map[string]interface{}{"id": id}) + if err != nil { + return "", fmt.Errorf("record not found") + } + if strings.TrimSpace(orm.AsString(rec["state"])) == "done" { + return "", nil + } + if !activityActorAllowed(ctx, rec) { + return "", fmt.Errorf("access denied") + } + return "", orm.UpdateRecordByID(ctx, "mail.activity", id, map[string]interface{}{ + "state": "done", + "date_done": time.Now().UTC().Format("2006-01-02"), + }) +} + +func actionSnooze(ctx context.Context, model string, id int, _ map[string]string) (string, error) { + if model != "mail.activity" || id <= 0 { + return "", fmt.Errorf("record not found") + } + rec, err := orm.SearchOne(ctx, "mail.activity", map[string]interface{}{"id": id}) + if err != nil { + return "", fmt.Errorf("record not found") + } + if strings.TrimSpace(orm.AsString(rec["state"])) == "done" { + return "", fmt.Errorf("done activities cannot be snoozed") + } + if !activityActorAllowed(ctx, rec) { + return "", fmt.Errorf("access denied") + } + days := 1 + typeID := recordID(rec["activity_type_id"]) + if typeID > 0 { + typeRow, err := orm.SearchOne(ctx, "mail.activity.type", map[string]interface{}{"id": int(typeID)}) + if err == nil { + if n, ok := orm.CoerceInt64(typeRow["delay_count"]); ok && n > 0 { + days = int(n) + } + } + } + base := time.Now().UTC() + if raw := strings.TrimSpace(orm.AsString(rec["date_deadline"])); len(raw) >= 10 { + if parsed, err := time.Parse("2006-01-02", raw[:10]); err == nil { + base = parsed + } + } + next := base.AddDate(0, 0, days).Format("2006-01-02") + return "", orm.UpdateRecordByID(ctx, "mail.activity", id, map[string]interface{}{ + "date_deadline": next, + }) +} + +func activityActorAllowed(ctx context.Context, rec map[string]interface{}) bool { + uid := orm.SecurityUID(ctx) + if uid > 0 && recordID(rec["user_id"]) == int64(uid) { + return true + } + return orm.UserHasGroupXML(ctx, uid, "base.group_system") +} + +func recordID(v interface{}) int64 { + if n, ok := orm.CoerceInt64(v); ok { + return n + } + items, ok := v.([]interface{}) + if ok && len(items) > 0 { + n, _ := orm.CoerceInt64(items[0]) + return n + } + return 0 +} diff --git a/addons/mail/data/mail_activity_type_data.xml b/addons/mail/data/mail_activity_type_data.xml new file mode 100644 index 00000000..abb497a3 --- /dev/null +++ b/addons/mail/data/mail_activity_type_data.xml @@ -0,0 +1,17 @@ + + + + + To Do + 1 + + + Call + 0 + + + Email + 2 + + + diff --git a/addons/mail/init.go b/addons/mail/init.go new file mode 100644 index 00000000..53bb45f6 --- /dev/null +++ b/addons/mail/init.go @@ -0,0 +1,9 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package mail + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/platform" + _ "sumeru/addons/mail/models" +) diff --git a/addons/mail/manifest.json b/addons/mail/manifest.json new file mode 100644 index 00000000..aeabd7a7 --- /dev/null +++ b/addons/mail/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "mail", + "display_name": "Mail", + "version": "1.0.0", + "author": "Sumeru", + "description": "Record messages and activities. Outbound transport stays in core/mail.", + "depends": ["base", "platform"], + "application": false, + "data": [ + "security/security.xml", + "security/sys.access.csv", + "data/mail_activity_type_data.xml", + "views/actions.xml", + "views/mail_activity_type_list_views.xml", + "views/mail_activity_type_form_views.xml", + "views/mail_activity_list_views.xml", + "views/mail_activity_form_views.xml", + "views/mail_activity_activity_views.xml", + "views/mail_message_list_views.xml", + "views/mail_message_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/mail/models/mail_activity.go b/addons/mail/models/mail_activity.go new file mode 100644 index 00000000..8aa4ea63 --- /dev/null +++ b/addons/mail/models/mail_activity.go @@ -0,0 +1,17 @@ +package models + +import "sumeru/core/sdk" + +type MailActivity struct { + sdk.Model `sumeru:"model=mail.activity"` + + ResModel sdk.String `sumeru:"required,index,column=model,string=Document Model"` + ResID sdk.Integer `sumeru:"required,index,string=Document"` + ActivityTypeID sdk.Many2One[MailActivityType] `sumeru:"required,index,string=Type"` + UserID sdk.Many2One[CoreUser] `sumeru:"index,string=Assigned To"` + DateDeadline sdk.Date `sumeru:"index,string=Due Date"` + Summary sdk.String `sumeru:"string=Summary"` + Note sdk.Text `sumeru:"string=Note"` + State sdk.String `sumeru:"index,string=State,default=planned,selection=planned:Planned,done:Done"` + DateDone sdk.Date `sumeru:"string=Done On"` +} diff --git a/addons/mail/models/mail_activity_type.go b/addons/mail/models/mail_activity_type.go new file mode 100644 index 00000000..0f826b27 --- /dev/null +++ b/addons/mail/models/mail_activity_type.go @@ -0,0 +1,11 @@ +package models + +import "sumeru/core/sdk" + +type MailActivityType struct { + sdk.Model `sumeru:"model=mail.activity.type"` + + Name sdk.String `sumeru:"required,unique,string=Name"` + DelayCount sdk.Integer `sumeru:"string=Delay (days),default=0"` + Icon sdk.String `sumeru:"string=Icon"` +} diff --git a/addons/mail/models/mail_message.go b/addons/mail/models/mail_message.go new file mode 100644 index 00000000..74d48eb2 --- /dev/null +++ b/addons/mail/models/mail_message.go @@ -0,0 +1,19 @@ +package models + +import "sumeru/core/sdk" + +type MailMessage struct { + sdk.Model `sumeru:"model=mail.message"` + + ResModel sdk.String `sumeru:"index,column=model,string=Document Model"` + ResID sdk.Integer `sumeru:"index,string=Document"` + MessageType sdk.String `sumeru:"string=Type,default=email,selection=email:Email,comment:Comment"` + Subject sdk.String `sumeru:"string=Subject"` + Body sdk.Text `sumeru:"string=Body"` + EmailFrom sdk.String `sumeru:"string=From"` + MessageID sdk.String `sumeru:"index,unique,column=message_id,string=Message-ID"` + InReplyTo sdk.String `sumeru:"index,string=In-Reply-To"` + AuthorID sdk.Many2One[CorePartner] `sumeru:"string=Author"` + State sdk.String `sumeru:"index,string=State,default=received,selection=received:Received,quarantine:Quarantine,rejected:Rejected"` + CompanyID sdk.Many2One[CoreCompany] `sumeru:"string=Company"` +} diff --git a/addons/mail/models/zmodels.go b/addons/mail/models/zmodels.go new file mode 100644 index 00000000..742fd0b3 --- /dev/null +++ b/addons/mail/models/zmodels.go @@ -0,0 +1,13 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("mail", + &MailActivity{}, + &MailActivityType{}, + &MailMessage{}, + ) +} diff --git a/addons/mail/models/zrefs.go b/addons/mail/models/zrefs.go new file mode 100644 index 00000000..9c746168 --- /dev/null +++ b/addons/mail/models/zrefs.go @@ -0,0 +1,17 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import ( + basemodels "sumeru/addons/base/models" +) + +// CoreCompany → core.company +type CoreCompany = basemodels.CoreCompany + +// CorePartner → core.partner +type CorePartner = basemodels.CorePartner + +// CoreUser → core.user +type CoreUser = basemodels.CoreUser + diff --git a/addons/mail/parent_filter.go b/addons/mail/parent_filter.go new file mode 100644 index 00000000..cbc8cb12 --- /dev/null +++ b/addons/mail/parent_filter.go @@ -0,0 +1,34 @@ +package mail + +import ( + "context" + "strings" + + "sumeru/core/orm" +) + +func filterParentReadable(ctx context.Context, model string, rows []map[string]interface{}) []map[string]interface{} { + if model != "mail.message" && model != "mail.activity" { + return rows + } + if orm.SecurityBypass(ctx) { + return rows + } + manager := orm.UserHasGroupXML(ctx, orm.SecurityUID(ctx), "mail.group_mail_manager") + out := make([]map[string]interface{}, 0, len(rows)) + for _, row := range rows { + parent := strings.TrimSpace(orm.AsString(row["model"])) + parentID := recordID(row["res_id"]) + if parent == "" || parentID <= 0 || parent == "mail.message" || parent == "mail.activity" { + if model == "mail.message" && strings.TrimSpace(orm.AsString(row["state"])) == "quarantine" && manager { + out = append(out, row) + } + continue + } + if _, err := orm.SearchOne(ctx, parent, map[string]interface{}{"id": int(parentID)}); err != nil { + continue + } + out = append(out, row) + } + return out +} diff --git a/addons/mail/security/security.xml b/addons/mail/security/security.xml new file mode 100644 index 00000000..3c30a734 --- /dev/null +++ b/addons/mail/security/security.xml @@ -0,0 +1,19 @@ + + + + + Mail + 40 + + + Mail / User + + + + + Mail / Manager + + + + + diff --git a/addons/mail/security/sys.access.csv b/addons/mail/security/sys.access.csv new file mode 100644 index 00000000..f5d33c8c --- /dev/null +++ b/addons/mail/security/sys.access.csv @@ -0,0 +1,7 @@ +id,name,model,group_id,perm_read,perm_write,perm_create,perm_unlink +access_mail_message_user,access_mail_message_user,mail.message,mail.group_mail_user,1,0,1,0 +access_mail_message_manager,access_mail_message_manager,mail.message,mail.group_mail_manager,1,1,1,1 +access_mail_activity_user,access_mail_activity_user,mail.activity,mail.group_mail_user,1,1,1,0 +access_mail_activity_manager,access_mail_activity_manager,mail.activity,mail.group_mail_manager,1,1,1,1 +access_mail_activity_type_user,access_mail_activity_type_user,mail.activity.type,mail.group_mail_user,1,0,0,0 +access_mail_activity_type_manager,access_mail_activity_type_manager,mail.activity.type,mail.group_mail_manager,1,1,1,1 diff --git a/addons/mail/views/actions.xml b/addons/mail/views/actions.xml new file mode 100644 index 00000000..744fb127 --- /dev/null +++ b/addons/mail/views/actions.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/addons/mail/views/mail_activity_activity_views.xml b/addons/mail/views/mail_activity_activity_views.xml new file mode 100644 index 00000000..ca5ba2f3 --- /dev/null +++ b/addons/mail/views/mail_activity_activity_views.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_activity_form_views.xml b/addons/mail/views/mail_activity_form_views.xml new file mode 100644 index 00000000..3ef90a17 --- /dev/null +++ b/addons/mail/views/mail_activity_form_views.xml @@ -0,0 +1,26 @@ + + + + +
+
+ + + + + + + + + + + + + + + +
+
+
diff --git a/addons/mail/views/mail_activity_list_views.xml b/addons/mail/views/mail_activity_list_views.xml new file mode 100644 index 00000000..2583054d --- /dev/null +++ b/addons/mail/views/mail_activity_list_views.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_activity_type_form_views.xml b/addons/mail/views/mail_activity_type_form_views.xml new file mode 100644 index 00000000..0dfe0d84 --- /dev/null +++ b/addons/mail/views/mail_activity_type_form_views.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_activity_type_list_views.xml b/addons/mail/views/mail_activity_type_list_views.xml new file mode 100644 index 00000000..7971a67b --- /dev/null +++ b/addons/mail/views/mail_activity_type_list_views.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/mail/views/mail_message_form_views.xml b/addons/mail/views/mail_message_form_views.xml new file mode 100644 index 00000000..7c8d965e --- /dev/null +++ b/addons/mail/views/mail_message_form_views.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_message_list_views.xml b/addons/mail/views/mail_message_list_views.xml new file mode 100644 index 00000000..b56f1095 --- /dev/null +++ b/addons/mail/views/mail_message_list_views.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/mail/views/menus.xml b/addons/mail/views/menus.xml new file mode 100644 index 00000000..03e35aae --- /dev/null +++ b/addons/mail/views/menus.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/cmd/sumeru/zimports.go b/cmd/sumeru/zimports.go index 7f66655b..3dedaaf2 100644 --- a/cmd/sumeru/zimports.go +++ b/cmd/sumeru/zimports.go @@ -12,5 +12,6 @@ import ( _ "sumeru/addons/digest" _ "sumeru/addons/platform" _ "sumeru/addons/audit" + _ "sumeru/addons/mail" _ "sumeru/addons/automation" ) diff --git a/core/engine/swcmeta/arch.go b/core/engine/swcmeta/arch.go index a2e1e979..8fa8fa40 100644 --- a/core/engine/swcmeta/arch.go +++ b/core/engine/swcmeta/arch.go @@ -57,6 +57,11 @@ func SerializeViewForUser(ctx context.Context, view *parser.View) ViewArch { arch.Graph = &GraphMeta{Chart: view.GraphChart()} } switch strings.ToLower(strings.TrimSpace(view.Type)) { + case "activity": + arch.Activity = &ActivityMeta{} + if ds := strings.TrimSpace(view.DateStart); ds != "" { + arch.Calendar = &CalendarMeta{DateStart: ds} + } case "calendar": arch.Calendar = &CalendarMeta{ DateStart: strings.TrimSpace(view.DateStart), @@ -341,10 +346,10 @@ func serializeButtons(buttons []parser.Button) []ArchButton { out := make([]ArchButton, 0, len(buttons)) for _, b := range buttons { ab := ArchButton{ - Name: strings.TrimSpace(b.Name), - String: strings.TrimSpace(b.String), - Type: strings.TrimSpace(b.Type), - Class: strings.TrimSpace(b.Class), + Name: strings.TrimSpace(b.Name), + String: strings.TrimSpace(b.String), + Type: strings.TrimSpace(b.Type), + Class: strings.TrimSpace(b.Class), Confirm: strings.TrimSpace(b.Confirm), } if lit, truthy, expr, err := parser.ParseModifierAttr("invisible", b.Invisible); err == nil && lit { diff --git a/core/engine/swcmeta/types.go b/core/engine/swcmeta/types.go index 0560e8fc..1302a190 100644 --- a/core/engine/swcmeta/types.go +++ b/core/engine/swcmeta/types.go @@ -2,34 +2,34 @@ package swcmeta // WorkspacePayload is the JSON body for GET /web/swc/workspace. type WorkspacePayload struct { - ActionID int `json:"actionId"` - MenuID string `json:"menuId"` - ViewType string `json:"viewType"` - Model string `json:"model"` - RecordID int `json:"recordId"` - FormEdit bool `json:"formEdit"` - CSRFToken string `json:"csrfToken"` - Arch ViewArch `json:"arch"` - Record map[string]interface{} `json:"record,omitempty"` - Records []map[string]interface{} `json:"records,omitempty"` - ViewTabs []ViewTab `json:"viewTabs"` - Breadcrumbs []Breadcrumb `json:"breadcrumbs"` - ListSearch string `json:"listSearch,omitempty"` - ListSearchURL string `json:"listSearchUrl,omitempty"` - ListTotal int `json:"listTotal,omitempty"` - ListSort string `json:"listSort,omitempty"` - ListOffset int `json:"listOffset,omitempty"` - ListFilter string `json:"listFilter,omitempty"` - ListDomain string `json:"listDomain,omitempty"` - ListGroupBy string `json:"listGroupBy,omitempty"` - ListSections []ListSectionMeta `json:"listSections,omitempty"` - Favorites []SavedSearchMeta `json:"favorites,omitempty"` - FormBaseQuery string `json:"formBaseQuery,omitempty"` - Defaults map[string]interface{} `json:"defaults,omitempty"` - IframeURL string `json:"iframeUrl,omitempty"` - UserSecurity *UserSecurityPayload `json:"userSecurity,omitempty"` - RecordMessagesEligible bool `json:"recordMessagesEligible,omitempty"` - RecordLogEligible bool `json:"recordLogEligible,omitempty"` + ActionID int `json:"actionId"` + MenuID string `json:"menuId"` + ViewType string `json:"viewType"` + Model string `json:"model"` + RecordID int `json:"recordId"` + FormEdit bool `json:"formEdit"` + CSRFToken string `json:"csrfToken"` + Arch ViewArch `json:"arch"` + Record map[string]interface{} `json:"record,omitempty"` + Records []map[string]interface{} `json:"records,omitempty"` + ViewTabs []ViewTab `json:"viewTabs"` + Breadcrumbs []Breadcrumb `json:"breadcrumbs"` + ListSearch string `json:"listSearch,omitempty"` + ListSearchURL string `json:"listSearchUrl,omitempty"` + ListTotal int `json:"listTotal,omitempty"` + ListSort string `json:"listSort,omitempty"` + ListOffset int `json:"listOffset,omitempty"` + ListFilter string `json:"listFilter,omitempty"` + ListDomain string `json:"listDomain,omitempty"` + ListGroupBy string `json:"listGroupBy,omitempty"` + ListSections []ListSectionMeta `json:"listSections,omitempty"` + Favorites []SavedSearchMeta `json:"favorites,omitempty"` + FormBaseQuery string `json:"formBaseQuery,omitempty"` + Defaults map[string]interface{} `json:"defaults,omitempty"` + IframeURL string `json:"iframeUrl,omitempty"` + UserSecurity *UserSecurityPayload `json:"userSecurity,omitempty"` + RecordMessagesEligible bool `json:"recordMessagesEligible,omitempty"` + RecordLogEligible bool `json:"recordLogEligible,omitempty"` } type ViewTab struct { @@ -52,25 +52,26 @@ type ListSectionMeta struct { } type ViewArch struct { - Type string `json:"type"` - Model string `json:"model"` - Title string `json:"title,omitempty"` - HasChatter bool `json:"hasChatter,omitempty"` - Fields []ArchField `json:"fields"` - Header *ArchHeader `json:"header,omitempty"` - Footer *ArchFooter `json:"footer,omitempty"` - Sheet *ArchSheet `json:"sheet,omitempty"` - FormMeta *FormMeta `json:"formMeta,omitempty"` - Kanban *KanbanMeta `json:"kanban,omitempty"` - Pivot *PivotMeta `json:"pivot,omitempty"` - Search *SearchMeta `json:"search,omitempty"` - Graph *GraphMeta `json:"graph,omitempty"` - Calendar *CalendarMeta `json:"calendar,omitempty"` - Gantt *GanttMeta `json:"gantt,omitempty"` - Map *MapMeta `json:"map,omitempty"` - Cohort *CohortMeta `json:"cohort,omitempty"` + Type string `json:"type"` + Model string `json:"model"` + Title string `json:"title,omitempty"` + HasChatter bool `json:"hasChatter,omitempty"` + Fields []ArchField `json:"fields"` + Header *ArchHeader `json:"header,omitempty"` + Footer *ArchFooter `json:"footer,omitempty"` + Sheet *ArchSheet `json:"sheet,omitempty"` + FormMeta *FormMeta `json:"formMeta,omitempty"` + Kanban *KanbanMeta `json:"kanban,omitempty"` + Pivot *PivotMeta `json:"pivot,omitempty"` + Search *SearchMeta `json:"search,omitempty"` + Graph *GraphMeta `json:"graph,omitempty"` + Activity *ActivityMeta `json:"activity,omitempty"` + Calendar *CalendarMeta `json:"calendar,omitempty"` + Gantt *GanttMeta `json:"gantt,omitempty"` + Map *MapMeta `json:"map,omitempty"` + Cohort *CohortMeta `json:"cohort,omitempty"` Hierarchy *HierarchyMeta `json:"hierarchy,omitempty"` - Report *ReportMeta `json:"report,omitempty"` + Report *ReportMeta `json:"report,omitempty"` } type FormMeta struct { @@ -115,13 +116,13 @@ type ArchListSubview struct { } type ArchButton struct { - Name string `json:"name"` - String string `json:"string"` - Type string `json:"type"` - Class string `json:"class,omitempty"` - Invisible bool `json:"invisible,omitempty"` - InvisibleExpr string `json:"invisible_expr,omitempty"` - Confirm string `json:"confirm,omitempty"` + Name string `json:"name"` + String string `json:"string"` + Type string `json:"type"` + Class string `json:"class,omitempty"` + Invisible bool `json:"invisible,omitempty"` + InvisibleExpr string `json:"invisible_expr,omitempty"` + Confirm string `json:"confirm,omitempty"` } type ArchHeader struct { @@ -224,6 +225,8 @@ type GraphMeta struct { Chart string `json:"chart,omitempty"` } +type ActivityMeta struct{} + type CalendarMeta struct { DateStart string `json:"dateStart,omitempty"` DateStop string `json:"dateStop,omitempty"` diff --git a/core/engine/templates/shell/base.html b/core/engine/templates/shell/base.html index a51ae6b7..910e2d4d 100644 --- a/core/engine/templates/shell/base.html +++ b/core/engine/templates/shell/base.html @@ -4,6 +4,7 @@ + {{if .CSRFToken}}{{end}} {{if .ModuleName}}{{.ModuleName}} | {{end}}{{.AppName}} {{range .ExtraStylesheetURLs}} @@ -273,6 +274,8 @@

{{.Name}}

aria-selected="true" data-sum-activity-tab="messages">Messages + Activities +
{{if .Error}}

{{.Error}}

{{end}} diff --git a/core/server/web/login.go b/core/server/web/login.go index 4477bc88..c2077475 100644 --- a/core/server/web/login.go +++ b/core/server/web/login.go @@ -485,8 +485,7 @@ func LoginPost(w http.ResponseWriter, r *http.Request) { } func LogoutGet(w http.ResponseWriter, r *http.Request) { - DestroySession(w, r) - clearLoginNextCookie(w) + // GET must not destroy the session (prefetch, keepalive, bookmarks). Use LogoutPost. http.Redirect(w, r, loginRoute, http.StatusFound) } diff --git a/core/server/web/portal_handlers.go b/core/server/web/portal_handlers.go index c1ef1bad..1826068e 100644 --- a/core/server/web/portal_handlers.go +++ b/core/server/web/portal_handlers.go @@ -20,6 +20,7 @@ type portalHomeData struct { AppName string LogoURL string Stylesheets []string + CSRFToken string Records []portalRecordRow Error string } @@ -57,6 +58,7 @@ func PortalHomeHandler(w http.ResponseWriter, r *http.Request) { AppName: render.AppDisplayName, LogoURL: render.ShellLogoURL(), Stylesheets: assets.PortalStylesheetURLs(), + CSRFToken: CSRFTokenForRequest(r), } if !orm.PortalAllowsModel(portalHomeModel, "read") { data.Error = "Portal access not configured" diff --git a/core/swc/src/services/http.ts b/core/swc/src/services/http.ts index 5396f1ff..8b8871f3 100644 --- a/core/swc/src/services/http.ts +++ b/core/swc/src/services/http.ts @@ -1,4 +1,5 @@ import { SwcError } from "../runtime/error.js"; +import { redirectToLoginIfNeeded, shouldRedirectForHttpStatus } from "./session-redirect.js"; export class HttpService { private readonly csrfToken: string; @@ -17,6 +18,9 @@ export class HttpService { headers: { Accept: "application/json" }, }); if (!res.ok) { + if (shouldRedirectForHttpStatus(res.status)) { + redirectToLoginIfNeeded(); + } throw new SwcError(`GET ${url} failed: ${res.status}`, "http_get"); } return (await res.json()) as T; @@ -84,6 +88,9 @@ export class HttpService { body: JSON.stringify(payload), }); if (!res.ok) { + if (shouldRedirectForHttpStatus(res.status)) { + redirectToLoginIfNeeded(); + } throw new SwcError(`POST ${url} failed: ${res.status}`, "http_post"); } return (await res.json()) as T; @@ -99,6 +106,9 @@ export class HttpService { }, }); if (!res.ok) { + if (shouldRedirectForHttpStatus(res.status)) { + redirectToLoginIfNeeded(); + } throw new SwcError(`DELETE ${url} failed: ${res.status}`, "http_delete"); } } diff --git a/core/swc/src/services/rpc.ts b/core/swc/src/services/rpc.ts index be3bffae..e16e52b0 100644 --- a/core/swc/src/services/rpc.ts +++ b/core/swc/src/services/rpc.ts @@ -1,4 +1,9 @@ import { SwcError } from "../runtime/error.js"; +import { + redirectToLoginIfNeeded, + shouldRedirectForHttpStatus, + shouldRedirectForRpcError, +} from "./session-redirect.js"; interface RpcEnvelope { ok?: boolean; @@ -49,6 +54,9 @@ export class RpcService { body: JSON.stringify(body), }); if (!res.ok) { + if (shouldRedirectForHttpStatus(res.status)) { + redirectToLoginIfNeeded(); + } throw new SwcError(`RPC HTTP ${res.status}`, "rpc_http"); } const raw: unknown = await res.json(); @@ -57,6 +65,9 @@ export class RpcService { } const data = raw as RpcEnvelope; if (data.ok === false || data.error) { + if (shouldRedirectForRpcError(data.error?.code)) { + redirectToLoginIfNeeded(); + } throw new SwcError(data.error?.message ?? "RPC failed", "rpc_error", data.error); } return data.result as T; diff --git a/core/swc/src/services/session-redirect.ts b/core/swc/src/services/session-redirect.ts new file mode 100644 index 00000000..97003cd0 --- /dev/null +++ b/core/swc/src/services/session-redirect.ts @@ -0,0 +1,35 @@ +const LOGIN_PATH = "/web/login"; + +let redirectScheduled = false; + +/** Resets redirect guard (Vitest only). */ +export function resetSessionRedirectForTests(): void { + redirectScheduled = false; +} + +/** Full navigation to login with return path; skips when already on auth routes. */ +export function redirectToLoginIfNeeded(): void { + if (redirectScheduled || typeof window === "undefined") { + return; + } + const path = window.location.pathname; + if (path === LOGIN_PATH || path.startsWith("/web/auth/")) { + return; + } + redirectScheduled = true; + const returnPath = window.location.pathname + window.location.search; + const params = new URLSearchParams(); + if (returnPath && returnPath !== LOGIN_PATH) { + params.set("next", returnPath); + } + const query = params.toString(); + window.location.assign(LOGIN_PATH + (query ? `?${query}` : "")); +} + +export function shouldRedirectForHttpStatus(status: number): boolean { + return status === 401; +} + +export function shouldRedirectForRpcError(code: string | undefined): boolean { + return code === "UNAUTHORIZED"; +} diff --git a/core/swc/tests/services/rpc.test.ts b/core/swc/tests/services/rpc.test.ts index 1d99c0a7..ffe1f81a 100644 --- a/core/swc/tests/services/rpc.test.ts +++ b/core/swc/tests/services/rpc.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { RpcService } from "../../src/services/rpc.js"; import { SwcError } from "../../src/runtime/error.js"; +import { resetSessionRedirectForTests } from "../../src/services/session-redirect.js"; describe("RpcService", () => { beforeEach(() => { + resetSessionRedirectForTests(); vi.stubGlobal("fetch", vi.fn()); }); @@ -49,6 +51,15 @@ describe("RpcService", () => { await expect(rpc.read("m", [1])).rejects.toBeInstanceOf(SwcError); }); + it("redirects to login on HTTP 401", async () => { + const assign = vi.fn(); + vi.stubGlobal("location", { pathname: "/web/home", search: "", assign }); + vi.mocked(fetch).mockResolvedValue({ ok: false, status: 401 } as Response); + const rpc = new RpcService("/web/swc/rpc", "tok"); + await expect(rpc.read("m", [1])).rejects.toBeInstanceOf(SwcError); + expect(assign).toHaveBeenCalledWith("/web/login?next=%2Fweb%2Fhome"); + }); + it("create and unlink call dispatch", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, diff --git a/core/swc/tests/services/session-redirect.test.ts b/core/swc/tests/services/session-redirect.test.ts new file mode 100644 index 00000000..bcb4be8e --- /dev/null +++ b/core/swc/tests/services/session-redirect.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + redirectToLoginIfNeeded, + resetSessionRedirectForTests, + shouldRedirectForHttpStatus, + shouldRedirectForRpcError, +} from "../../src/services/session-redirect.js"; + +describe("session-redirect", () => { + beforeEach(() => { + resetSessionRedirectForTests(); + vi.stubGlobal("location", { pathname: "/web/home", search: "", assign: vi.fn() }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("detects unauthorized HTTP and RPC codes", () => { + expect(shouldRedirectForHttpStatus(401)).toBe(true); + expect(shouldRedirectForHttpStatus(403)).toBe(false); + expect(shouldRedirectForRpcError("UNAUTHORIZED")).toBe(true); + expect(shouldRedirectForRpcError("ACCESS_DENIED")).toBe(false); + }); + + it("redirects to login with next path", () => { + const assign = vi.fn(); + vi.stubGlobal("location", { + pathname: "/web/home", + search: "?menu_id=1", + assign, + }); + redirectToLoginIfNeeded(); + expect(assign).toHaveBeenCalledWith("/web/login?next=%2Fweb%2Fhome%3Fmenu_id%3D1"); + }); + + it("skips redirect on login route", () => { + const assign = vi.fn(); + vi.stubGlobal("location", { pathname: "/web/login", search: "", assign }); + redirectToLoginIfNeeded(); + expect(assign).not.toHaveBeenCalled(); + }); +}); From a0292e7d8bf0340715d16921d7fcca732c1bd8b5 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Mon, 12 Oct 2026 23:54:32 +0530 Subject: [PATCH 10/13] feat(web): optional close-session when last browser tab stops heartbeating Add shell tab lease and POST /web/session/touch, browser_seen_at on sys.session, grace checks in resolveSession, and config keys in sumeru.conf.example. --- core/engine/render/swc_bootstrap.go | 31 +++- core/orm/sys_session.go | 7 +- core/server/config/config.go | 103 ++++++----- core/server/config/const.go | 87 ++++----- core/server/web/auth.go | 22 ++- core/server/web/routes_table.go | 1 + core/server/web/session_browser.go | 45 +++++ core/server/web/session_touch.go | 22 +++ core/server/web/testexports.go | 21 +++ core/server/web/web_constants.go | 123 ++++++------- core/swc/src/main.ts | 2 + core/swc/src/shell/shell-tab-presence.ts | 170 ++++++++++++++++++ core/swc/src/types/bootstrap.ts | 3 + .../tests/shell/shell-tab-presence.test.ts | 30 ++++ sumeru.conf.example | 6 + test/core/server/web/session_touch_test.go | 54 ++++++ 16 files changed, 569 insertions(+), 158 deletions(-) create mode 100644 core/server/web/session_browser.go create mode 100644 core/server/web/session_touch.go create mode 100644 core/swc/src/shell/shell-tab-presence.ts create mode 100644 core/swc/tests/shell/shell-tab-presence.test.ts create mode 100644 test/core/server/web/session_touch_test.go diff --git a/core/engine/render/swc_bootstrap.go b/core/engine/render/swc_bootstrap.go index 33d9dbdb..cd6c5ff6 100644 --- a/core/engine/render/swc_bootstrap.go +++ b/core/engine/render/swc_bootstrap.go @@ -8,6 +8,7 @@ import ( "sumeru/addons/im" "sumeru/core/orm" + "sumeru/core/server/config" ) // SWCBootstrapWorkspace is the initial workspace route embedded in bootstrap JSON. @@ -40,8 +41,11 @@ type swcBootstrap struct { ActivityEnabled bool `json:"activityEnabled"` InternalMessagesEnabled bool `json:"internalMessagesEnabled"` CompanyFormHref string `json:"companyFormHref,omitempty"` - BusEnabled bool `json:"busEnabled"` - DocsURL string `json:"docsUrl"` + BusEnabled bool `json:"busEnabled"` + CloseSessionOnLastTab bool `json:"closeSessionOnLastTab"` + SessionBrowserPingSeconds int `json:"sessionBrowserPingSeconds"` + SessionBrowserGraceSeconds int `json:"sessionBrowserGraceSeconds"` + DocsURL string `json:"docsUrl"` ProfileURL string `json:"profileUrl"` Features map[string]bool `json:"features,omitempty"` Workspace *SWCBootstrapWorkspace `json:"workspace,omitempty"` @@ -105,8 +109,11 @@ func BuildSWCBootstrapJSON(ctx context.Context, page PageData, ws *SWCBootstrapW ActivityEnabled: shellActivityEnabled(ctx, page), InternalMessagesEnabled: im.IMEnabled(ctx), CompanyFormHref: im.CompanySettingsFormHref(ctx, page.ShellActiveCompanyID), - BusEnabled: true, - DocsURL: page.UserDocsHref, + BusEnabled: true, + CloseSessionOnLastTab: config.AppConfig.CloseSessionOnLastTab, + SessionBrowserPingSeconds: sessionBrowserPingSecondsForBootstrap(), + SessionBrowserGraceSeconds: sessionBrowserGraceSecondsForBootstrap(), + DocsURL: page.UserDocsHref, ProfileURL: page.UserProfileHref, Features: buildSWCFeatureFlags(ctx), Workspace: ws, @@ -208,6 +215,22 @@ func parseSWCPinnedApps(raw template.JS) []string { return out } +func sessionBrowserPingSecondsForBootstrap() int { + sec := config.AppConfig.SessionBrowserPingSeconds + if sec <= 0 { + return 30 + } + return sec +} + +func sessionBrowserGraceSecondsForBootstrap() int { + sec := config.AppConfig.SessionBrowserGraceSeconds + if sec <= 0 { + return 90 + } + return sec +} + func buildSWCFeatureFlags(ctx context.Context) map[string]bool { uid := orm.UIDFromContext(ctx) if uid <= 0 { diff --git a/core/orm/sys_session.go b/core/orm/sys_session.go index 22bf847f..017dcfc2 100644 --- a/core/orm/sys_session.go +++ b/core/orm/sys_session.go @@ -7,7 +7,8 @@ import ( type SysSession struct { modelmeta.ModelMeta `sumeru:"model=sys.session"` - Sid modelmeta.String `sumeru:"required,unique,index"` - UserID modelmeta.Many2One[CoreUser] `sumeru:"required,index,string=User"` - ExpiresAt modelmeta.DateTime `sumeru:"required"` + Sid modelmeta.String `sumeru:"required,unique,index"` + UserID modelmeta.Many2One[CoreUser] `sumeru:"required,index,string=User"` + ExpiresAt modelmeta.DateTime `sumeru:"required"` + BrowserSeenAt modelmeta.DateTime `sumeru:"column=browser_seen_at"` } diff --git a/core/server/config/config.go b/core/server/config/config.go index 7164309d..0037bffc 100644 --- a/core/server/config/config.go +++ b/core/server/config/config.go @@ -10,51 +10,54 @@ import ( ) type Config struct { - DbHost string - DbPort string - DbUser string - DbPass string - DbName string - DbSslMode string - HttpPort string - HttpInterface string // optional bind address; empty = all interfaces (:port); ignored during setup when setup_localhost_only=true - AddonsPath string // raw from file: comma-separated addon directory roots (see AddonPaths after AbsPaths) - AddonPaths []string // absolute addon roots from addons_path; filled by AbsPaths() - SumeruHome string // optional: directory of standard sumeru repo (go.mod); used for default assets/templates if set - AssetsPath string // static files (CSS/JS); default core/engine/assets - TemplatesPath string // HTML templates; default core/engine/templates - BrandCSS string // optional path to extra CSS (served as /static/brand.css) - LogoPath string // optional image path (served as /static/app-logo) - FilestorePath string // attachment blob directory; default data/filestore under INI dir - CompanyDisplayName string // optional header label; else first core.company when module installed - UserDisplayName string // optional header label; else first core.user when module installed - LogFile string // optional log file path (absolutized in AbsPaths); see log_stdout / log_rolling - LogStdout bool // when true, emit JSON logs to stdout (typical for Kubernetes) - LogRolling bool // when true and log_file set, use size-based rotation (lumberjack); false for append-only or external rotation - LogMaxSizeMB int // max megabytes per log file before rotation (default 100 when log_rolling) - LogMaxBackups int // retained rotated files (0 = lumberjack default) - LogMaxAgeDays int // delete rolled files older than N days (0 = no age limit) - LogEnabled bool // log_enabled: when false, no slog sinks and L(ctx) is no-op; stdlib log discarded - LogTimezone string // log_timezone: UTC, Local (default), or IANA (e.g. Asia/Kolkata) for timestamps - DevMode bool // dev_mode INI key; parseBoolKey(..., false) — debug slog level and dev-only server paths - DevFeatures string // dev_features INI: comma-separated sql, access, xml - SetupToken string // secret for POST /setup/init; required when setup_localhost_only is false (startup fails if empty) - SetupLocalhostOnly bool // when true (default), setup binds 127.0.0.1 only and ignores http_interface; false requires setup_token - DbMaxOpenConns int // db_max_open_conns; 0 = Go default - DbMaxIdleConns int // db_max_idle_conns; 0 = Go default - DbConnMaxLifetimeMin int // db_conn_max_lifetime_minutes; 0 = no limit - DbReadReplicaDSN string // optional libpq DSN for read replica (search/read_group RPC) - RateLimitRPM int // rate_limit_rpm per client IP on /api/rpc and login; 0 = disabled - TrustedProxies string // trusted_proxies: comma-separated CIDRs/IPs allowed to set X-Forwarded-For; empty = never trust XFF - CSRFSecret string // csrf_secret: shared HMAC key for multi-instance; empty = ephemeral per process - ForceSecureCookies bool // force_secure_cookies: set Secure on session/flash cookies even when dev_mode=true - SessionCookieStrictSameSite bool // session_cookie_strict_samesite: use SameSite=Strict on session cookie (default Lax) - MetricsScrapeToken string // metrics_scrape_token: Bearer token for unauthenticated /metrics scrape; empty = admin session only - SMTPHost string - SMTPPort int - SMTPUser string - SMTPPassword string - SMTPFrom string + DbHost string + DbPort string + DbUser string + DbPass string + DbName string + DbSslMode string + HttpPort string + HttpInterface string // optional bind address; empty = all interfaces (:port); ignored during setup when setup_localhost_only=true + AddonsPath string // raw from file: comma-separated addon directory roots (see AddonPaths after AbsPaths) + AddonPaths []string // absolute addon roots from addons_path; filled by AbsPaths() + SumeruHome string // optional: directory of standard sumeru repo (go.mod); used for default assets/templates if set + AssetsPath string // static files (CSS/JS); default core/engine/assets + TemplatesPath string // HTML templates; default core/engine/templates + BrandCSS string // optional path to extra CSS (served as /static/brand.css) + LogoPath string // optional image path (served as /static/app-logo) + FilestorePath string // attachment blob directory; default data/filestore under INI dir + CompanyDisplayName string // optional header label; else first core.company when module installed + UserDisplayName string // optional header label; else first core.user when module installed + LogFile string // optional log file path (absolutized in AbsPaths); see log_stdout / log_rolling + LogStdout bool // when true, emit JSON logs to stdout (typical for Kubernetes) + LogRolling bool // when true and log_file set, use size-based rotation (lumberjack); false for append-only or external rotation + LogMaxSizeMB int // max megabytes per log file before rotation (default 100 when log_rolling) + LogMaxBackups int // retained rotated files (0 = lumberjack default) + LogMaxAgeDays int // delete rolled files older than N days (0 = no age limit) + LogEnabled bool // log_enabled: when false, no slog sinks and L(ctx) is no-op; stdlib log discarded + LogTimezone string // log_timezone: UTC, Local (default), or IANA (e.g. Asia/Kolkata) for timestamps + DevMode bool // dev_mode INI key; parseBoolKey(..., false) — debug slog level and dev-only server paths + DevFeatures string // dev_features INI: comma-separated sql, access, xml + SetupToken string // secret for POST /setup/init; required when setup_localhost_only is false (startup fails if empty) + SetupLocalhostOnly bool // when true (default), setup binds 127.0.0.1 only and ignores http_interface; false requires setup_token + DbMaxOpenConns int // db_max_open_conns; 0 = Go default + DbMaxIdleConns int // db_max_idle_conns; 0 = Go default + DbConnMaxLifetimeMin int // db_conn_max_lifetime_minutes; 0 = no limit + DbReadReplicaDSN string // optional libpq DSN for read replica (search/read_group RPC) + RateLimitRPM int // rate_limit_rpm per client IP on /api/rpc and login; 0 = disabled + TrustedProxies string // trusted_proxies: comma-separated CIDRs/IPs allowed to set X-Forwarded-For; empty = never trust XFF + CSRFSecret string // csrf_secret: shared HMAC key for multi-instance; empty = ephemeral per process + ForceSecureCookies bool // force_secure_cookies: set Secure on session/flash cookies even when dev_mode=true + SessionCookieStrictSameSite bool // session_cookie_strict_samesite: use SameSite=Strict on session cookie (default Lax) + CloseSessionOnLastTab bool // close_session_on_last_tab: shell tab lease + browser heartbeat (default false) + SessionBrowserGraceSeconds int // session_browser_grace_seconds: revoke session when browser_seen_at older than this (default 90) + SessionBrowserPingSeconds int // session_browser_ping_seconds: client touch interval hint (default 30) + MetricsScrapeToken string // metrics_scrape_token: Bearer token for unauthenticated /metrics scrape; empty = admin session only + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPassword string + SMTPFrom string } var AppConfig Config @@ -190,6 +193,16 @@ func LoadConfig(path string) error { AppConfig.ForceSecureCookies = parseBoolKey(val, false) case keySessionCookieStrictSameSite: AppConfig.SessionCookieStrictSameSite = parseBoolKey(val, false) + case keyCloseSessionOnLastTab: + AppConfig.CloseSessionOnLastTab = parseBoolKey(val, false) + case keySessionBrowserGraceSeconds: + if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil && n > 0 { + AppConfig.SessionBrowserGraceSeconds = n + } + case keySessionBrowserPingSeconds: + if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil && n > 0 { + AppConfig.SessionBrowserPingSeconds = n + } case keyMetricsScrapeToken: AppConfig.MetricsScrapeToken = val case keySMTPHost: diff --git a/core/server/config/const.go b/core/server/config/const.go index 3cb95472..b3325524 100644 --- a/core/server/config/const.go +++ b/core/server/config/const.go @@ -9,50 +9,53 @@ const ( iniSeparator = "=" iniKeyValueParts = 2 - keyDbHost = "db_host" - keyDbPort = "db_port" - keyDbUser = "db_user" - keyDbPassword = "db_password" - keyDbName = "db_name" - keyDbSSLMode = "db_sslmode" - keyHTTPPort = "http_port" - keyHTTPInterface = "http_interface" - keyAddonsPath = "addons_path" - keySumeruHome = "sumeru_home" - keyAssetsPath = "assets_path" - keyTemplatesPath = "templates_path" - keyBrandCSS = "brand_css" - keyLogoPath = "logo_path" - keyFilestorePath = "filestore_path" - keyCompanyDisplayName = "company_display_name" - keyUserDisplayName = "user_display_name" - keyLogFile = "log_file" - keyLogStdout = "log_stdout" - keyLogRolling = "log_rolling" - keyLogMaxSizeMB = "log_max_size_mb" - keyLogMaxBackups = "log_max_backups" - keyLogMaxAgeDays = "log_max_age_days" - keyLogEnabled = "log_enabled" - keyLogTimezone = "log_timezone" - keyDevMode = "dev_mode" - keyDevFeatures = "dev_features" - keySetupToken = "setup_token" - keySetupLocalhostOnly = "setup_localhost_only" - keyDbMaxOpenConns = "db_max_open_conns" - keyDbMaxIdleConns = "db_max_idle_conns" - keyDbConnMaxLifetimeMin = "db_conn_max_lifetime_minutes" - keyDbReadReplicaDSN = "db_read_replica_dsn" - keyRateLimitRPM = "rate_limit_rpm" - keyTrustedProxies = "trusted_proxies" - keyCSRFSecret = "csrf_secret" + keyDbHost = "db_host" + keyDbPort = "db_port" + keyDbUser = "db_user" + keyDbPassword = "db_password" + keyDbName = "db_name" + keyDbSSLMode = "db_sslmode" + keyHTTPPort = "http_port" + keyHTTPInterface = "http_interface" + keyAddonsPath = "addons_path" + keySumeruHome = "sumeru_home" + keyAssetsPath = "assets_path" + keyTemplatesPath = "templates_path" + keyBrandCSS = "brand_css" + keyLogoPath = "logo_path" + keyFilestorePath = "filestore_path" + keyCompanyDisplayName = "company_display_name" + keyUserDisplayName = "user_display_name" + keyLogFile = "log_file" + keyLogStdout = "log_stdout" + keyLogRolling = "log_rolling" + keyLogMaxSizeMB = "log_max_size_mb" + keyLogMaxBackups = "log_max_backups" + keyLogMaxAgeDays = "log_max_age_days" + keyLogEnabled = "log_enabled" + keyLogTimezone = "log_timezone" + keyDevMode = "dev_mode" + keyDevFeatures = "dev_features" + keySetupToken = "setup_token" + keySetupLocalhostOnly = "setup_localhost_only" + keyDbMaxOpenConns = "db_max_open_conns" + keyDbMaxIdleConns = "db_max_idle_conns" + keyDbConnMaxLifetimeMin = "db_conn_max_lifetime_minutes" + keyDbReadReplicaDSN = "db_read_replica_dsn" + keyRateLimitRPM = "rate_limit_rpm" + keyTrustedProxies = "trusted_proxies" + keyCSRFSecret = "csrf_secret" keyForceSecureCookies = "force_secure_cookies" keySessionCookieStrictSameSite = "session_cookie_strict_samesite" - keyMetricsScrapeToken = "metrics_scrape_token" - keySMTPHost = "smtp_host" - keySMTPPort = "smtp_port" - keySMTPUser = "smtp_user" - keySMTPPassword = "smtp_password" - keySMTPFrom = "smtp_from" + keyCloseSessionOnLastTab = "close_session_on_last_tab" + keySessionBrowserGraceSeconds = "session_browser_grace_seconds" + keySessionBrowserPingSeconds = "session_browser_ping_seconds" + keyMetricsScrapeToken = "metrics_scrape_token" + keySMTPHost = "smtp_host" + keySMTPPort = "smtp_port" + keySMTPUser = "smtp_user" + keySMTPPassword = "smtp_password" + keySMTPFrom = "smtp_from" ) const ( diff --git a/core/server/web/auth.go b/core/server/web/auth.go index 0f63ed17..c67377d2 100644 --- a/core/server/web/auth.go +++ b/core/server/web/auth.go @@ -180,12 +180,13 @@ func resolveSession(r *http.Request) sessionState { var userID int var active bool + var browserSeen sql.NullTime err := orm.DB.QueryRowContext(r.Context(), - `SELECT s.user_id, u.active FROM `+sessionTbl+` s + `SELECT s.user_id, u.active, s.browser_seen_at FROM `+sessionTbl+` s JOIN `+userTbl+` u ON u.id = s.user_id WHERE s.sid = $1 AND s.expires_at > NOW()`, sid, - ).Scan(&userID, &active) + ).Scan(&userID, &active, &browserSeen) if err != nil { if err != sql.ErrNoRows { applog.WarnCode(r.Context(), errcode.InternalError, "session lookup failed", applog.Event{ @@ -198,6 +199,15 @@ func resolveSession(r *http.Request) sessionState { deleteSession(r.Context(), sid) return sessionState{clearCookie: true} } + now := time.Now().UTC() + if sessionBrowserPresenceEnabled() { + if !browserSeen.Valid { + touchBrowserSeen(r.Context(), sid) + } else if browserSeenExpired(browserSeen, now) { + deleteSession(r.Context(), sid) + return sessionState{clearCookie: true} + } + } if !active || userID <= 0 { deleteSession(r.Context(), sid) applog.WarnCode(r.Context(), errcode.AccessDenied, "session revoked for inactive user", applog.Event{ @@ -252,7 +262,13 @@ func CreateSession(w http.ResponseWriter, userID int) error { sessionID := hex.EncodeToString(sessionBytes) expiresAt := time.Now().UTC().Add(sessionDuration) sessionTbl := orm.MustQuotedTableName("sys.session") - if _, err := orm.DB.Exec(`INSERT INTO `+sessionTbl+` (sid, user_id, expires_at) VALUES ($1, $2, $3)`, sessionID, userID, expiresAt); err != nil { + var err error + if sessionBrowserPresenceEnabled() { + _, err = orm.DB.Exec(`INSERT INTO `+sessionTbl+` (sid, user_id, expires_at, browser_seen_at) VALUES ($1, $2, $3, NOW())`, sessionID, userID, expiresAt) + } else { + _, err = orm.DB.Exec(`INSERT INTO `+sessionTbl+` (sid, user_id, expires_at) VALUES ($1, $2, $3)`, sessionID, userID, expiresAt) + } + if err != nil { return err } setSessionCookie(w, sessionID, false) diff --git a/core/server/web/routes_table.go b/core/server/web/routes_table.go index ec176cbe..4a4f64fb 100644 --- a/core/server/web/routes_table.go +++ b/core/server/web/routes_table.go @@ -57,6 +57,7 @@ func registerAuthRoutes() { registerPublic(http.MethodPost, loginRoute, LoginPost) registerPublic(http.MethodGet, logoutRoute, LogoutGet) registerSession(http.MethodPost, logoutRoute, LogoutPost) + registerSession(http.MethodPost, sessionTouchRoute, SessionTouchPost) registerAPIKeyRevealRoute() registerOAuthRoutes() registerTOTPRoutes() diff --git a/core/server/web/session_browser.go b/core/server/web/session_browser.go new file mode 100644 index 00000000..06d40675 --- /dev/null +++ b/core/server/web/session_browser.go @@ -0,0 +1,45 @@ +package web + +import ( + "context" + "database/sql" + "time" + + "sumeru/core/orm" + "sumeru/core/server/config" +) + +const defaultSessionBrowserGraceSeconds = 90 + +func sessionBrowserPresenceEnabled() bool { + return config.AppConfig.CloseSessionOnLastTab +} + +func sessionBrowserGrace() time.Duration { + sec := config.AppConfig.SessionBrowserGraceSeconds + if sec <= 0 { + sec = defaultSessionBrowserGraceSeconds + } + return time.Duration(sec) * time.Second +} + +func touchBrowserSeen(ctx context.Context, sid string) { + if orm.DB == nil || sid == "" || !sessionBrowserPresenceEnabled() { + return + } + sessionTbl := orm.MustQuotedTableName("sys.session") + _, _ = orm.DB.ExecContext(ctx, + `UPDATE `+sessionTbl+` SET browser_seen_at = NOW() WHERE sid = $1 AND expires_at > NOW()`, + sid, + ) +} + +func browserSeenExpired(seen sql.NullTime, now time.Time) bool { + if !sessionBrowserPresenceEnabled() { + return false + } + if !seen.Valid { + return false + } + return now.Sub(seen.Time) > sessionBrowserGrace() +} diff --git a/core/server/web/session_touch.go b/core/server/web/session_touch.go new file mode 100644 index 00000000..757fc042 --- /dev/null +++ b/core/server/web/session_touch.go @@ -0,0 +1,22 @@ +package web + +import ( + "net/http" +) + +func SessionTouchPost(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if !sessionBrowserPresenceEnabled() { + w.WriteHeader(http.StatusNoContent) + return + } + if !ParsePostForm(w, r) || !validateSessionCSRF(w, r) || !requireLogin(w, r) { + return + } + sid, _ := sessionCookieFromRequest(r) + touchBrowserSeen(r.Context(), sid) + w.WriteHeader(http.StatusNoContent) +} diff --git a/core/server/web/testexports.go b/core/server/web/testexports.go index 2b03127e..5a57db3f 100644 --- a/core/server/web/testexports.go +++ b/core/server/web/testexports.go @@ -2,6 +2,7 @@ package web import ( "context" + "database/sql" "fmt" "net/http" "time" @@ -488,6 +489,11 @@ func OAuthCallbackForTest(w http.ResponseWriter, r *http.Request) { OAuthCallbac // OAuthStartForTest exposes the OAuth start handler for tests. func OAuthStartForTest(w http.ResponseWriter, r *http.Request) { OAuthStartHandler(w, r) } +// LinkOAuthUserForTest exposes OAuth account linking and JIT provisioning for tests. +func LinkOAuthUserForTest(ctx context.Context, providerID int, subject, email, policy string) (int, error) { + return linkOAuthUser(ctx, providerID, subject, email, policy) +} + // AuthLocalEnabledForTest reports whether password login is shown on the login page. func AuthLocalEnabledForTest(ctx context.Context) bool { return authLocalEnabled(ctx) } @@ -557,6 +563,21 @@ func LogoutGetForTest(w http.ResponseWriter, r *http.Request) { LogoutGet(w, r) // LogoutPostForTest exposes the logout POST handler for tests. func LogoutPostForTest(w http.ResponseWriter, r *http.Request) { LogoutPost(w, r) } +// TestSessionTouchRoute is POST /web/session/touch for browser presence. +const TestSessionTouchRoute = sessionTouchRoute + +// SessionTouchPostForTest exposes the session touch handler for tests. +func SessionTouchPostForTest(w http.ResponseWriter, r *http.Request) { SessionTouchPost(w, r) } + +// BrowserSeenExpiredForTest reports whether browser_seen_at is past configured grace. +func BrowserSeenExpiredForTest(seenValid bool, seenUnix int64) bool { + var seen sql.NullTime + if seenValid { + seen = sql.NullTime{Time: time.Unix(seenUnix, 0).UTC(), Valid: true} + } + return browserSeenExpired(seen, time.Now().UTC()) +} + // CSRFTokenForRequestForTest exposes the session-bound CSRF token for tests. func CSRFTokenForRequestForTest(r *http.Request) string { return CSRFTokenForRequest(r) } diff --git a/core/server/web/web_constants.go b/core/server/web/web_constants.go index 76ba511a..aeadffea 100644 --- a/core/server/web/web_constants.go +++ b/core/server/web/web_constants.go @@ -4,28 +4,29 @@ import "sumeru/core/engine/render" // Shared routes used across web handlers. const ( - rootRoute = "/" - loginRoute = "/web/login" - logoutRoute = "/web/logout" - homeRoute = "/web/home" - portalRoutePrefix = "/portal" - portalHomeRoute = "/portal/home" - portalRecordRoute = "/portal/record" + rootRoute = "/" + loginRoute = "/web/login" + logoutRoute = "/web/logout" + sessionTouchRoute = "/web/session/touch" + homeRoute = "/web/home" + portalRoutePrefix = "/portal" + portalHomeRoute = "/portal/home" + portalRecordRoute = "/portal/record" portalShareRoutePrefix = "/portal/share/" - companySwitchRoute = "/web/company/switch" - kanbanMoveRoute = "/web/kanban/move" - moduleActionRoute = "/web/module/action" - resetPasswordRoute = "/web/action/reset_password" - createAPIKeyRoute = "/web/action/create_api_key" - pinnedAppsRoute = "/web/user/pinned-apps" - settingsRoute = "/web/settings" - appLogsRoute = "/web/settings/app-logs" - apiHealthRoute = "/api/health" - apiReadyRoute = "/api/ready" - apiRPCRoute = "/api/rpc" - metricsRoute = "/metrics" - setupRoute = "/setup" - setupInitRoute = "/setup/init" + companySwitchRoute = "/web/company/switch" + kanbanMoveRoute = "/web/kanban/move" + moduleActionRoute = "/web/module/action" + resetPasswordRoute = "/web/action/reset_password" + createAPIKeyRoute = "/web/action/create_api_key" + pinnedAppsRoute = "/web/user/pinned-apps" + settingsRoute = "/web/settings" + appLogsRoute = "/web/settings/app-logs" + apiHealthRoute = "/api/health" + apiReadyRoute = "/api/ready" + apiRPCRoute = "/api/rpc" + metricsRoute = "/metrics" + setupRoute = "/setup" + setupInitRoute = "/setup/init" ) // Setup wizard limits and templates. @@ -147,16 +148,16 @@ const ( // Settings hub page identifiers. const ( - settingsRootMenuXMLID = "base.menu_settings_root" - settingsHubMenuXMLID = render.MenuGeneralSettingsXMLID - settingsHubInnerTemplate = render.TemplatePagesSettingsHub - settingsHubPageTitle = "Settings" - settingsHubStylesheetURL = "/static/css/sumeru-settings-hub.css" - settingsHubBodyClass = " sum-body--settings-hub" + settingsRootMenuXMLID = "base.menu_settings_root" + settingsHubMenuXMLID = render.MenuGeneralSettingsXMLID + settingsHubInnerTemplate = render.TemplatePagesSettingsHub + settingsHubPageTitle = "Settings" + settingsHubStylesheetURL = "/static/css/sumeru-settings-hub.css" + settingsHubBodyClass = " sum-body--settings-hub" settingsAccountInnerTemplate = render.TemplatePagesSettingsAccount settingsFieldACLInnerTemplate = render.TemplatePagesSettingsFieldACL settingsModelACLInnerTemplate = render.TemplatePagesSettingsModelACL - groupUserXML = "base.group_user" + groupUserXML = "base.group_user" ) // Apps module action form fields (POST apps_*). @@ -220,21 +221,21 @@ const ( // Workspace view modes and row limits. const ( - workspaceViewModeList = render.ViewModeList - workspaceViewModeForm = render.ViewModeForm - workspaceViewModeKanban = render.ViewModeKanban - workspaceViewModePivot = render.ViewModePivot - workspaceViewModeGraph = render.ViewModeGraph - workspaceViewModeCalendar = render.ViewModeCalendar - workspaceViewModeGantt = render.ViewModeGantt - workspaceViewModeMap = render.ViewModeMap - workspaceViewModeCohort = render.ViewModeCohort + workspaceViewModeList = render.ViewModeList + workspaceViewModeForm = render.ViewModeForm + workspaceViewModeKanban = render.ViewModeKanban + workspaceViewModePivot = render.ViewModePivot + workspaceViewModeGraph = render.ViewModeGraph + workspaceViewModeCalendar = render.ViewModeCalendar + workspaceViewModeGantt = render.ViewModeGantt + workspaceViewModeMap = render.ViewModeMap + workspaceViewModeCohort = render.ViewModeCohort workspaceViewModeHierarchy = render.ViewModeHierarchy workspaceViewModeActivity = render.ViewModeActivity workspaceViewModeIframe = render.ViewModeIframe - maxWorkspaceListRows = 500 - maxWorkspaceKanbanRows = 200 - workspaceListPageSize = 40 + maxWorkspaceListRows = 500 + maxWorkspaceKanbanRows = 200 + workspaceListPageSize = 40 ) // ORM models used by workspace handlers. @@ -263,29 +264,29 @@ const ( // CSV import route, limits, and multipart form fields. const ( - importCSVRoute = "/web/import/csv" - exportCSVRoute = "/web/export/csv" - exportPDFRoute = "/web/export/pdf" - exportXLSXRoute = "/web/export/xlsx" - templatePDFRoute = "/web/report/template-pdf" - bulkTemplateRoute = "/web/bulk/template" - bulkUploadRoute = "/web/bulk/upload" - bulkConfirmRoute = "/web/bulk/confirm" - bulkCancelRoute = "/web/bulk/cancel" - bulkPreviewRoute = "/web/bulk/preview" + importCSVRoute = "/web/import/csv" + exportCSVRoute = "/web/export/csv" + exportPDFRoute = "/web/export/pdf" + exportXLSXRoute = "/web/export/xlsx" + templatePDFRoute = "/web/report/template-pdf" + bulkTemplateRoute = "/web/bulk/template" + bulkUploadRoute = "/web/bulk/upload" + bulkConfirmRoute = "/web/bulk/confirm" + bulkCancelRoute = "/web/bulk/cancel" + bulkPreviewRoute = "/web/bulk/preview" bulkDryRunRoute = "/web/bulk/dry-run" bulkSaveTemplateRoute = "/web/bulk/save-template" - exportAsyncRoute = "/web/export/async" - bulkStatusRoute = "/web/bulk/status" - importWizardRoute = "/web/import" - swcImportRoute = "/web/swc/import" - maxImportBodyBytes = 8 << 20 - importModelField = "model" - importFileField = "file" - reportFieldsParam = "fields" - reportPageSizeParam = "page_size" - importModeField = "import_mode" - actionIDField = "action" + exportAsyncRoute = "/web/export/async" + bulkStatusRoute = "/web/bulk/status" + importWizardRoute = "/web/import" + swcImportRoute = "/web/swc/import" + maxImportBodyBytes = 8 << 20 + importModelField = "model" + importFileField = "file" + reportFieldsParam = "fields" + reportPageSizeParam = "page_size" + importModeField = "import_mode" + actionIDField = "action" ) const recordModelField = "model" diff --git a/core/swc/src/main.ts b/core/swc/src/main.ts index f7dffe12..2a0fe912 100644 --- a/core/swc/src/main.ts +++ b/core/swc/src/main.ts @@ -17,6 +17,7 @@ import type { SwcBootstrap } from "./types/bootstrap.js"; import { registerCoreServices } from "./services/service-registry.js"; import { ShellLayout } from "./shell/ShellLayout.js"; import { initShellChrome } from "./shell/shell-chrome.js"; +import { initShellTabPresence } from "./shell/shell-tab-presence.js"; import { initAppLauncher } from "./shell/app-launcher.js"; import { registerDefaultWidgets } from "./widgets/registry.js"; import { registry, type ViewConstructor, type MainComponentConstructor } from "./runtime/registry.js"; @@ -126,6 +127,7 @@ function bootstrap(): void { initDebugManager(boot, { dialog: env.services.dialog, notification: env.services.notification }); mountDebugEnvironment(boot); void updateDebugDrawer(boot); + initShellTabPresence(boot); initShellChrome(boot, env.services.http, env.services.bus, env); initAppLauncher(boot, env.services.action, env.services.command); diff --git a/core/swc/src/shell/shell-tab-presence.ts b/core/swc/src/shell/shell-tab-presence.ts new file mode 100644 index 00000000..ecf3e876 --- /dev/null +++ b/core/swc/src/shell/shell-tab-presence.ts @@ -0,0 +1,170 @@ +import type { SwcBootstrap } from "../types/bootstrap.js"; + +const STORAGE_KEY = "sumeru_shell_tabs"; +const CHANNEL_NAME = "sumeru_shell_tabs"; +const TAB_ID_KEY = "sumeru_shell_tab_id"; +const LOGOUT_ROUTE = "/web/logout"; + +export type TabRegistry = Record; + +type TabMessage = + | { type: "sync"; registry: TabRegistry } + | { type: "touch"; tabId: string; at: number }; + +function readRegistry(): TabRegistry { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + return parsed as TabRegistry; + } catch { + return {}; + } +} + +function writeRegistry(registry: TabRegistry): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(registry)); +} + +export function pruneStaleTabs(registry: TabRegistry, staleMs: number, now = Date.now()): TabRegistry { + const out: TabRegistry = {}; + for (const [id, seen] of Object.entries(registry)) { + if (now-seen <= staleMs) { + out[id] = seen; + } + } + return out; +} + +export function registerTab(registry: TabRegistry, tabId: string, now = Date.now()): TabRegistry { + return { ...registry, [tabId]: now }; +} + +export function unregisterTab(registry: TabRegistry, tabId: string): TabRegistry { + const next = { ...registry }; + delete next[tabId]; + return next; +} + +export function registryIsEmpty(registry: TabRegistry): boolean { + return Object.keys(registry).length === 0; +} + +function csrfTokenFromPage(boot: SwcBootstrap): string { + const meta = document.querySelector('meta[name="sum-csrf-token"]'); + const fromMeta = meta?.getAttribute("content")?.trim(); + if (fromMeta) return fromMeta; + return boot.csrfToken; +} + +function postLogoutBeacon(csrfToken: string): void { + if (!csrfToken) return; + const body = new URLSearchParams({ csrf_token: csrfToken }); + if (typeof navigator.sendBeacon === "function") { + navigator.sendBeacon(LOGOUT_ROUTE, body); + return; + } + void fetch(LOGOUT_ROUTE, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + keepalive: true, + }); +} + +function touchSession(csrfToken: string): void { + if (!csrfToken) return; + const body = new URLSearchParams({ csrf_token: csrfToken }); + void fetch("/web/session/touch", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + keepalive: true, + }); +} + +function tabIdFromSession(): string { + let id = sessionStorage.getItem(TAB_ID_KEY); + if (!id) { + id = crypto.randomUUID(); + sessionStorage.setItem(TAB_ID_KEY, id); + } + return id; +} + +export function isReloadNavigation(): boolean { + if (typeof performance === "undefined") return false; + const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; + return nav?.type === "reload"; +} + +export function initShellTabPresence(boot: SwcBootstrap): void { + if (!boot.closeSessionOnLastTab || typeof window === "undefined") { + return; + } + + const tabId = tabIdFromSession(); + const csrfToken = csrfTokenFromPage(boot); + const pingMs = Math.max(5, boot.sessionBrowserPingSeconds ?? 30) * 1000; + const staleMs = Math.max(pingMs * 2, (boot.sessionBrowserGraceSeconds ?? 90) * 1000); + + let registry = registerTab(pruneStaleTabs(readRegistry(), staleMs), tabId); + writeRegistry(registry); + + const channel = + typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(CHANNEL_NAME) : null; + + const broadcast = (msg: TabMessage): void => { + channel?.postMessage(msg); + }; + + const applyRegistry = (next: TabRegistry): void => { + registry = next; + writeRegistry(registry); + broadcast({ type: "sync", registry }); + }; + + channel?.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + if (!msg || typeof msg !== "object") return; + if (msg.type === "sync") { + registry = registerTab(pruneStaleTabs(msg.registry, staleMs), tabId); + writeRegistry(registry); + } + if (msg.type === "touch") { + registry = registerTab(readRegistry(), msg.tabId, msg.at); + writeRegistry(registry); + } + }); + + window.addEventListener("pageshow", () => { + applyRegistry(registerTab(readRegistry(), tabId)); + touchSession(csrfToken); + }); + + window.addEventListener("pagehide", (ev: PageTransitionEvent) => { + if (ev.persisted || isReloadNavigation()) { + return; + } + const next = unregisterTab(readRegistry(), tabId); + writeRegistry(next); + broadcast({ type: "sync", registry: next }); + if (registryIsEmpty(next)) { + postLogoutBeacon(csrfToken); + } + }); + + const tick = (): void => { + const now = Date.now(); + const next = registerTab(pruneStaleTabs(readRegistry(), staleMs, now), tabId, now); + writeRegistry(next); + broadcast({ type: "touch", tabId, at: now }); + touchSession(csrfToken); + }; + + tick(); + window.setInterval(tick, pingMs); +} diff --git a/core/swc/src/types/bootstrap.ts b/core/swc/src/types/bootstrap.ts index d22a3317..8ed801af 100644 --- a/core/swc/src/types/bootstrap.ts +++ b/core/swc/src/types/bootstrap.ts @@ -59,6 +59,9 @@ export interface SwcBootstrap { internalMessagesEnabled?: boolean; companyFormHref?: string; busEnabled?: boolean; + closeSessionOnLastTab?: boolean; + sessionBrowserPingSeconds?: number; + sessionBrowserGraceSeconds?: number; docsUrl: string; profileUrl: string; /** e.g. studio, debugMenu (system admin developer tools in top bar) */ diff --git a/core/swc/tests/shell/shell-tab-presence.test.ts b/core/swc/tests/shell/shell-tab-presence.test.ts new file mode 100644 index 00000000..2ec6d2cf --- /dev/null +++ b/core/swc/tests/shell/shell-tab-presence.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + isReloadNavigation, + pruneStaleTabs, + registerTab, + registryIsEmpty, + unregisterTab, +} from "../../src/shell/shell-tab-presence.js"; + +describe("shell-tab-presence registry", () => { + it("registers and unregisters tabs", () => { + let reg = registerTab({}, "a", 100); + reg = registerTab(reg, "b", 200); + expect(registryIsEmpty(reg)).toBe(false); + reg = unregisterTab(reg, "a"); + expect(reg).toEqual({ b: 200 }); + reg = unregisterTab(reg, "b"); + expect(registryIsEmpty(reg)).toBe(true); + }); + + it("prunes stale tab entries", () => { + const reg = { old: 100, fresh: 5000 }; + const pruned = pruneStaleTabs(reg, 1000, 6000); + expect(pruned).toEqual({ fresh: 5000 }); + }); + + it("detects reload navigation type", () => { + expect(typeof isReloadNavigation()).toBe("boolean"); + }); +}); diff --git a/sumeru.conf.example b/sumeru.conf.example index 74578183..d2a3c5b1 100644 --- a/sumeru.conf.example +++ b/sumeru.conf.example @@ -57,6 +57,12 @@ log_rolling = false # db_conn_max_lifetime_minutes = 30 # db_read_replica_dsn = host=replica dbname=sumeru user=postgres password=postgres sslmode=disable +# Shell session: optional logout when all browser tabs are closed (POST /web/logout, never GET). +# Requires SWC shell; portal pages are not tracked. Default false. +# close_session_on_last_tab = false +# session_browser_ping_seconds = 30 +# session_browser_grace_seconds = 90 + # Rate limiting (requests per minute per client IP on /api/rpc and /web/login). # When dev_mode=false and this is unset/0, the server defaults to 120. rate_limit_rpm = 120 diff --git a/test/core/server/web/session_touch_test.go b/test/core/server/web/session_touch_test.go new file mode 100644 index 00000000..a8a97708 --- /dev/null +++ b/test/core/server/web/session_touch_test.go @@ -0,0 +1,54 @@ +package web_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sumeru/core/server/config" + "sumeru/core/server/web" +) + +func TestSessionTouchPost_disabledWithoutConfig(t *testing.T) { + prev := config.AppConfig.CloseSessionOnLastTab + config.AppConfig.CloseSessionOnLastTab = false + t.Cleanup(func() { config.AppConfig.CloseSessionOnLastTab = prev }) + + req := httptest.NewRequest(http.MethodPost, web.TestSessionTouchRoute, nil) + rec := httptest.NewRecorder() + web.SessionTouchPostForTest(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status=%d want 204", rec.Code) + } +} + +func TestSessionTouchPost_requiresCSRFWhenEnabled(t *testing.T) { + prev := config.AppConfig.CloseSessionOnLastTab + config.AppConfig.CloseSessionOnLastTab = true + t.Cleanup(func() { config.AppConfig.CloseSessionOnLastTab = prev }) + + req := httptest.NewRequest(http.MethodPost, web.TestSessionTouchRoute, strings.NewReader("csrf_token=bad")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + web.SessionTouchPostForTest(rec, req) + if rec.Code != http.StatusFound && rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden { + if rec.Code == http.StatusNoContent { + t.Fatal("expected auth/CSRF failure without session") + } + } +} + +func TestBrowserSeenExpiredForTest(t *testing.T) { + prev := config.AppConfig.CloseSessionOnLastTab + config.AppConfig.CloseSessionOnLastTab = true + config.AppConfig.SessionBrowserGraceSeconds = 60 + t.Cleanup(func() { + config.AppConfig.CloseSessionOnLastTab = prev + config.AppConfig.SessionBrowserGraceSeconds = 0 + }) + + if web.BrowserSeenExpiredForTest(false, 0) { + t.Fatal("null seen should not expire") + } +} From 6da038565c1a6da311cd355508660ca06227b7d7 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Mon, 12 Oct 2026 23:54:42 +0530 Subject: [PATCH 11/13] chore: document digest KPIs, extend OAuth session tests, remove dead SWC/CSS Add addons/digest README and compute test coverage; LinkOAuthUser edge cases; drop unused sum template parser path, debug aliases, and orphaned message CSS. --- addons/digest/README.md | 17 +++++ core/engine/assets/css/sumeru-messages.css | 17 ----- core/swc/src/devtools/debug.ts | 6 -- core/swc/src/template/sum/parser.ts | 5 -- test/addons/digest/compute_test.go | 17 +++-- .../web/auth_session_integration_test.go | 74 +++++++++++++++---- 6 files changed, 86 insertions(+), 50 deletions(-) create mode 100644 addons/digest/README.md diff --git a/addons/digest/README.md b/addons/digest/README.md new file mode 100644 index 00000000..0320ad3d --- /dev/null +++ b/addons/digest/README.md @@ -0,0 +1,17 @@ +# Digest addon + +Scheduled email digests with KPI rows backed by `digest.digest` and `digest.kpi`. + +## KPI compute codes + +`ComputeKPI` in [`compute.go`](compute.go) evaluates each KPI’s `compute_code` at send time. Built-in codes today: + +| Code | Meaning | +|------|---------| +| `crm.lead_count` | Count of `crm.lead` records | +| `crm.lead_won_count` | Won leads | +| `crm.opportunity_count` | Opportunities (`type = opportunity`) | + +When a module needs a new metric, add a `case` in `ComputeKPI` (or a shared registry helper in this package) and reference the code from KPI records in XML/data. Unknown codes return an error so bad configuration surfaces in cron logs instead of silent zeroes. + +If the target model is not installed, counts return `0` without error. diff --git a/core/engine/assets/css/sumeru-messages.css b/core/engine/assets/css/sumeru-messages.css index 02a527f2..9d3354a1 100644 --- a/core/engine/assets/css/sumeru-messages.css +++ b/core/engine/assets/css/sumeru-messages.css @@ -520,14 +520,6 @@ } } -/* Legacy card classes (unused in dock UI; kept for compatibility) */ -.sum-msg-card { - border-radius: var(--sum-radius-sm); - padding: 0.55rem 0.65rem; - background: var(--sum-surface); - border: 1px solid var(--sum-line); -} - .sum-msg-author { font-size: var(--sum-font-size-sm); font-weight: var(--sum-font-weight-semibold); @@ -539,12 +531,3 @@ color: var(--sum-muted); white-space: nowrap; } - -.sum-msg-card--out { - margin-left: 1.25rem; - background: var(--sum-header-tint-08); -} - -.sum-msg-card--pending { - opacity: 0.75; -} diff --git a/core/swc/src/devtools/debug.ts b/core/swc/src/devtools/debug.ts index 665d9349..28a70936 100644 --- a/core/swc/src/devtools/debug.ts +++ b/core/swc/src/devtools/debug.ts @@ -257,9 +257,6 @@ export function mountDebugEnvironment(boot: SwcBootstrap): void { enablePicker(); } -/** @deprecated Use mountDebugEnvironment */ -export const mountDebugPanel = mountDebugEnvironment; - export async function refreshDebugDrawer(): Promise { if (lastBoot) await updateDebugDrawer(lastBoot, lastWorkspace); } @@ -401,9 +398,6 @@ export async function updateDebugDrawer( body.append(section("Tools", toolsBody)); } -/** @deprecated Use updateDebugDrawer */ -export const updateDebugPanel = updateDebugDrawer; - export function logWorkspacePayload(label: string, payload: unknown): void { if (!isDebugMode()) return; console.debug(`[SWC ${label}]`, payload); diff --git a/core/swc/src/template/sum/parser.ts b/core/swc/src/template/sum/parser.ts index 5023c04c..7da20f22 100644 --- a/core/swc/src/template/sum/parser.ts +++ b/core/swc/src/template/sum/parser.ts @@ -138,8 +138,3 @@ export function parseSumDocument(source: string): SumDocument { } return { templates, main }; } - -/** @deprecated Use parseSumDocument; returns main template only. */ -export function parseSumXml(source: string): SumElement { - return parseSumDocument(source).main; -} diff --git a/test/addons/digest/compute_test.go b/test/addons/digest/compute_test.go index 08dfcd6a..dcb396a7 100644 --- a/test/addons/digest/compute_test.go +++ b/test/addons/digest/compute_test.go @@ -2,24 +2,25 @@ package digest_test import ( "context" + "strings" "testing" "sumeru/addons/digest" ) -func TestComputeKPIUnknownCode(t *testing.T) { - _, err := digest.ComputeKPI(context.Background(), "missing.code") - if err == nil { - t.Fatal("expected error for unknown compute code") +func TestComputeKPI_unknownCode(t *testing.T) { + _, err := digest.ComputeKPI(context.Background(), "not.registered") + if err == nil || !strings.Contains(err.Error(), "unknown compute code") { + t.Fatalf("expected unknown code error, got %v", err) } } -func TestComputeKPIMissingModelReturnsZero(t *testing.T) { - v, err := digest.ComputeKPI(context.Background(), "crm.lead_count") +func TestComputeKPI_missingModelReturnsZero(t *testing.T) { + val, err := digest.ComputeKPI(context.Background(), "crm.lead_count") if err != nil { t.Fatalf("unexpected error: %v", err) } - if v != 0 { - t.Fatalf("expected 0 when crm.lead absent, got %v", v) + if val != 0 { + t.Fatalf("got %v want 0 without crm module", val) } } diff --git a/test/core/server/web/auth_session_integration_test.go b/test/core/server/web/auth_session_integration_test.go index a4e93704..d12e68ed 100644 --- a/test/core/server/web/auth_session_integration_test.go +++ b/test/core/server/web/auth_session_integration_test.go @@ -3,6 +3,7 @@ package web_test import ( + "context" "database/sql" "fmt" "net/http" @@ -194,7 +195,7 @@ func moduleRootFromIntegration(t *testing.T) string { } } -func TestLogoutGetDestroysSession(t *testing.T) { +func TestLogoutGetPreservesSession(t *testing.T) { integrationDB(t) userID := existingActiveUserID(t) sid := "test-logout-get-" + time.Now().Format("150405.000000") @@ -219,19 +220,8 @@ func TestLogoutGetDestroysSession(t *testing.T) { if err != nil { t.Fatalf("count sessions: %v", err) } - if count != 0 { - t.Fatalf("session row count=%d want 0 after logout", count) - } - setCookies := rec.Result().Header.Values("Set-Cookie") - foundClear := false - for _, raw := range setCookies { - if strings.Contains(raw, web.TestSessionCookieName+"=") { - foundClear = true - break - } - } - if !foundClear { - t.Fatalf("expected cleared session cookie, got %v", setCookies) + if count != 1 { + t.Fatalf("session row count=%d want 1 after GET logout (POST destroys)", count) } } @@ -313,3 +303,59 @@ func TestInactiveUserSessionRevoked(t *testing.T) { t.Fatalf("expected cleared session cookie, got %v", setCookies) } } + +func TestLinkOAuthUser_linksExistingLoginByEmail(t *testing.T) { + integrationDB(t) + userID := insertTestUser(t) + userTable := orm.MustQuotedTableName("core.user") + var login string + if err := orm.DB.QueryRow(`SELECT login FROM `+userTable+` WHERE id = $1`, userID).Scan(&login); err != nil { + t.Fatalf("login lookup: %v", err) + } + ctx := orm.AuditedBypass(context.Background(), "test.oauth.link") + providerID := 9001 + subject := "oauth-sub-" + login + uid, err := web.LinkOAuthUserForTest(ctx, providerID, subject, login, "deny") + if err != nil || uid != userID { + t.Fatalf("link by email: uid=%d err=%v want %d", uid, err, userID) + } + uid2, err := web.LinkOAuthUserForTest(ctx, providerID, subject, login, "deny") + if err != nil || uid2 != userID { + t.Fatalf("identity reuse: uid=%d err=%v", uid2, err) + } + t.Cleanup(func() { + idTable := orm.MustQuotedTableName("core.user.identity") + _, _ = orm.DB.Exec(`DELETE FROM `+idTable+` WHERE provider_id = $1 AND subject = $2`, providerID, subject) + }) +} + +func TestLinkOAuthUser_jitInternalAndUnknownPolicy(t *testing.T) { + integrationDB(t) + ctx := orm.AuditedBypass(context.Background(), "test.oauth.jit") + providerID := 9002 + subject := fmt.Sprintf("jit_%d", time.Now().UnixNano()) + email := subject + "@example.com" + + uid, err := web.LinkOAuthUserForTest(ctx, providerID, subject, email, "jit_internal") + if err != nil || uid <= 0 { + t.Fatalf("jit_internal: uid=%d err=%v", uid, err) + } + userTable := orm.MustQuotedTableName("core.user") + var userType string + if err := orm.DB.QueryRow(`SELECT user_type FROM `+userTable+` WHERE id = $1`, uid).Scan(&userType); err != nil { + t.Fatalf("user_type: %v", err) + } + if userType != "internal" { + t.Fatalf("user_type=%q want internal", userType) + } + t.Cleanup(func() { + idTable := orm.MustQuotedTableName("core.user.identity") + _, _ = orm.DB.Exec(`DELETE FROM `+idTable+` WHERE user_id = $1`, uid) + _, _ = orm.DB.Exec(`DELETE FROM `+userTable+` WHERE id = $1`, uid) + }) + + _, err = web.LinkOAuthUserForTest(ctx, providerID+1, "deny-sub", "nobody@example.com", "deny") + if err == nil || !strings.Contains(err.Error(), "unknown user") { + t.Fatalf("deny policy: err=%v", err) + } +} From a6f3e7b3aef6493b82b6da05e2f460755fcf4b2c Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Mon, 12 Oct 2026 23:54:48 +0530 Subject: [PATCH 12/13] chore(deps): bump golang.org/x/crypto and SWC vitest toolchain Update x/crypto to v0.58.0 and vitest/jsdom patch releases; refresh package-lock.json (source-map-js audit fix). --- core/swc/package-lock.json | 397 ++++++++++++++++++------------------- core/swc/package.json | 6 +- go.mod | 2 +- go.sum | 4 +- 4 files changed, 197 insertions(+), 212 deletions(-) diff --git a/core/swc/package-lock.json b/core/swc/package-lock.json index f886a949..cb51d238 100644 --- a/core/swc/package-lock.json +++ b/core/swc/package-lock.json @@ -11,22 +11,22 @@ "chart.js": "^4.5.1" }, "devDependencies": { - "@vitest/coverage-v8": "^5.0.3", + "@vitest/coverage-v8": "^5.0.4", "esbuild": "^0.28.2", - "jsdom": "^30.1.1", + "jsdom": "^30.1.2", "typescript": "^7.0.2", - "vitest": "^5.0.3" + "vitest": "^5.0.4" } }, "node_modules/@asamuzakjp/css-color": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", - "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.5.tgz", + "integrity": "sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.4.2", - "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2", "lru-cache": "^11.5.3" @@ -72,9 +72,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { @@ -112,13 +112,13 @@ } }, "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", "dev": true, "license": "MIT", "dependencies": { - "css-tree": "^3.0.0" + "css-tree": "^3.2.1" }, "bin": { "specificity": "bin/cli.js" @@ -759,9 +759,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.152.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", - "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", "peer": true, @@ -770,9 +770,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", - "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "cpu": [ "arm" ], @@ -788,9 +788,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", - "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -806,9 +806,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", - "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -824,9 +824,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", - "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -842,9 +842,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", - "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -860,9 +860,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", - "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -878,9 +878,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", - "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], @@ -899,9 +899,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", - "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], @@ -920,9 +920,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", - "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], @@ -941,9 +941,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", - "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], @@ -962,9 +962,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", - "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], @@ -983,9 +983,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", - "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], @@ -1004,9 +1004,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", - "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -1022,9 +1022,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", - "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -1040,9 +1040,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", - "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -1431,27 +1431,27 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.3.tgz", - "integrity": "sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.4.tgz", + "integrity": "sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/istanbul-lib-coverage": "^1.0.0", - "@vitest/istanbul-lib-report": "^1.0.0", - "ast-v8-to-istanbul": "^1.0.5", - "magicast": "^0.5.4", - "obug": "^2.1.4", - "std-env": "^4.2.0", - "tinyrainbow": "^3.1.1" + "@vitest/istanbul-lib-coverage": "^1.0.2", + "@vitest/istanbul-lib-report": "^1.0.2", + "ast-v8-to-istanbul": "^1.0.7", + "magicast": "^0.5.5", + "obug": "^3.0.0", + "std-env": "^4.3.0", + "tinyrainbow": "^3.2.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "5.0.3", - "vitest": "5.0.3" + "@vitest/browser": "5.0.4", + "vitest": "5.0.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1460,9 +1460,9 @@ } }, "node_modules/@vitest/istanbul-lib-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", - "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", "dev": true, "license": "MIT", "engines": { @@ -1470,35 +1470,35 @@ } }, "node_modules/@vitest/istanbul-lib-report": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", - "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/istanbul-lib-coverage": "1.0.1" + "@vitest/istanbul-lib-coverage": "1.0.2" }, "engines": { "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", - "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.3", + "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -1511,9 +1511,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", - "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", "dev": true, "license": "MIT", "funding": { @@ -1531,9 +1531,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", - "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -1589,32 +1589,17 @@ } }, "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "whatwg-url": "^17.1.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/decimal.js": { @@ -1779,31 +1764,31 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", - "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^7.0.0", - "@asamuzakjp/dom-selector": "^9.2.1", + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.13", - "@exodus/bytes": "^1.15.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", "css-tree": "^3.2.1", - "data-urls": "^7.0.0", + "data-urls": "^8.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2", + "lru-cache": "^11.5.3", "parse5": "^8.0.1", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", - "undici": "^8.10.2", + "undici": "^8.11.2", "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.1", + "whatwg-url": "^17.2.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -2114,9 +2099,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", - "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2124,9 +2109,9 @@ } }, "node_modules/magicast": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", - "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { @@ -2143,9 +2128,9 @@ "license": "CC0-1.0" }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -2163,9 +2148,9 @@ } }, "node_modules/obug": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", - "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2211,9 +2196,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -2232,9 +2217,9 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2261,14 +2246,14 @@ } }, "node_modules/rolldown": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", - "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@oxc-project/types": "=0.152.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2278,21 +2263,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.12", - "@rolldown/binding-android-arm64": "1.2.12", - "@rolldown/binding-darwin-arm64": "1.2.12", - "@rolldown/binding-darwin-x64": "1.2.12", - "@rolldown/binding-freebsd-x64": "1.2.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", - "@rolldown/binding-linux-arm64-gnu": "1.2.12", - "@rolldown/binding-linux-arm64-musl": "1.2.12", - "@rolldown/binding-linux-ppc64-gnu": "1.2.12", - "@rolldown/binding-linux-s390x-gnu": "1.2.12", - "@rolldown/binding-linux-x64-gnu": "1.2.12", - "@rolldown/binding-linux-x64-musl": "1.2.12", - "@rolldown/binding-openharmony-arm64": "1.2.12", - "@rolldown/binding-win32-arm64-msvc": "1.2.12", - "@rolldown/binding-win32-x64-msvc": "1.2.12" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/saxes": { @@ -2309,9 +2294,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2326,9 +2311,9 @@ "license": "MIT" }, "node_modules/tinybench": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", - "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -2363,9 +2348,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -2373,22 +2358,22 @@ } }, "node_modules/tldts": { - "version": "7.4.16", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", - "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.16" + "tldts-core": "^7.4.18" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.16", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", - "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", "dev": true, "license": "MIT" }, @@ -2406,16 +2391,16 @@ } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=20" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/typescript": { @@ -2464,17 +2449,17 @@ } }, "node_modules/vite": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", - "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.11", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -2543,23 +2528,23 @@ } }, "node_modules/vitest": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", - "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.3", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "^6.1.4", - "tinyexec": "^1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, @@ -2576,12 +2561,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.3", - "@vitest/browser-preview": "5.0.3", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.3", - "@vitest/coverage-v8": "5.0.3", - "@vitest/ui": "5.0.3", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -2659,14 +2644,14 @@ } }, "node_modules/whatwg-url": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", - "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.15.1", - "tr46": "^6.0.0", + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", "webidl-conversions": "^8.0.1" }, "engines": { diff --git a/core/swc/package.json b/core/swc/package.json index 719eb81f..9bfa62eb 100644 --- a/core/swc/package.json +++ b/core/swc/package.json @@ -12,11 +12,11 @@ "test:coverage": "vitest run --coverage" }, "devDependencies": { - "@vitest/coverage-v8": "^5.0.3", + "@vitest/coverage-v8": "^5.0.4", "esbuild": "^0.28.2", - "jsdom": "^30.1.1", + "jsdom": "^30.1.2", "typescript": "^7.0.2", - "vitest": "^5.0.3" + "vitest": "^5.0.4" }, "dependencies": { "chart.js": "^4.5.1" diff --git a/go.mod b/go.mod index 2d9ebd37..589104ca 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/gpdf-dev/gpdf v1.0.13 github.com/lib/pq v1.12.3 github.com/piglig/go-qr v1.1.0 - golang.org/x/crypto v0.57.0 + golang.org/x/crypto v0.58.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) diff --git a/go.sum b/go.sum index 56a68352..e45ab2f6 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 4453c74dd0b7a184e1a8a8cb7e3dde2eb29fe488 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Tue, 13 Oct 2026 00:38:35 +0530 Subject: [PATCH 13/13] Complete PLAT-18/21/24: monetary UI, properties admin, activity board Wire currency_field through arch and MonetaryField; add platform.pricing.sample. Add sys.property.definition views, partner extra_properties, and PropertiesField polish. Group activity board by type; link activities from the messages panel; extend tests. --- .../data/partner_property_definition_data.xml | 30 ++++++++++++++++ addons/base/manifest.json | 3 ++ addons/base/models/core_partner.go | 1 + addons/base/views/menus.xml | 4 +++ addons/base/views/security_actions.xml | 1 + .../sys_property_definition_form_views.xml | 20 +++++++++++ .../sys_property_definition_list_views.xml | 14 ++++++++ addons/contacts/manifest.json | 1 + addons/contacts/views/actions.xml | 3 ++ .../core_partner_properties_inherit_views.xml | 13 +++++++ addons/platform/init.go | 1 + addons/platform/manifest.json | 5 ++- .../models/platform_pricing_sample.go | 14 ++++++++ addons/platform/models/zmodels.go | 1 + addons/platform/models/zrefs.go | 4 +++ addons/platform/security/sys.access.csv | 1 + addons/platform/views/menus.xml | 3 ++ .../views/platform_pricing_sample_actions.xml | 7 ++++ .../platform_pricing_sample_form_views.xml | 14 ++++++++ .../platform_pricing_sample_list_views.xml | 10 ++++++ core/engine/assets/css/sumeru-views.css | 33 ++++++++++++++++++ core/engine/swcmeta/arch.go | 11 ++++++ core/swc/src/shell/internal-messages-panel.ts | 12 +++++-- core/swc/src/views/activity/ActivityView.ts | 34 +++++++++++++++---- core/swc/src/views/shared/field-display.ts | 11 ++++++ core/swc/src/widgets/PropertiesField.ts | 6 ++++ core/swc/src/widgets/extra-fields.ts | 15 +++++++- core/swc/tests/views/activity-board.test.ts | 34 +++++++++++++++++++ core/swc/tests/views/form-widgets.test.ts | 10 ++++++ core/swc/tests/views/properties-field.test.ts | 21 ++++++++++++ .../models/partner_properties_field_test.go | 23 +++++++++++++ .../platform/models/pricing_sample_test.go | 26 ++++++++++++++ test/core/engine/swcmeta/arch_test.go | 25 ++++++++++++++ 33 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 addons/base/data/partner_property_definition_data.xml create mode 100644 addons/base/views/sys_property_definition_form_views.xml create mode 100644 addons/base/views/sys_property_definition_list_views.xml create mode 100644 addons/contacts/views/core_partner_properties_inherit_views.xml create mode 100644 addons/platform/models/platform_pricing_sample.go create mode 100644 addons/platform/views/platform_pricing_sample_actions.xml create mode 100644 addons/platform/views/platform_pricing_sample_form_views.xml create mode 100644 addons/platform/views/platform_pricing_sample_list_views.xml create mode 100644 test/addons/base/models/partner_properties_field_test.go create mode 100644 test/addons/platform/models/pricing_sample_test.go diff --git a/addons/base/data/partner_property_definition_data.xml b/addons/base/data/partner_property_definition_data.xml new file mode 100644 index 00000000..77d9b6a1 --- /dev/null +++ b/addons/base/data/partner_property_definition_data.xml @@ -0,0 +1,30 @@ + + + + + core.partner + extra_properties + tier + Tier + selection + bronze:Bronze,silver:Silver,gold:Gold + 10 + + + core.partner + extra_properties + score + Score + float + 20 + + + core.partner + extra_properties + vip + VIP + boolean + 30 + + + diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 753acb68..05fa99b5 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -7,6 +7,7 @@ "application": true, "data": [ "data/core_company_data.xml", + "data/partner_property_definition_data.xml", "views/core_company_actions.xml", "views/core_users_actions.xml", "views/platform_actions.xml", @@ -34,6 +35,8 @@ "views/sys_rule_form_views.xml", "views/sys_field_access_list_views.xml", "views/sys_field_access_form_views.xml", + "views/sys_property_definition_list_views.xml", + "views/sys_property_definition_form_views.xml", "views/menus.xml" ] } diff --git a/addons/base/models/core_partner.go b/addons/base/models/core_partner.go index 9b6cacc7..88f742ac 100644 --- a/addons/base/models/core_partner.go +++ b/addons/base/models/core_partner.go @@ -22,4 +22,5 @@ type CorePartner struct { PropertyAccountPayableID sdk.Many2One[sdk.Any] `sumeru:"string=Payable Account,comodel=account.account"` Color sdk.Integer `sumeru:"string=Color Index"` Gender sdk.String `sumeru:"string=Gender,selection=male:Male,female:Female,other:Other"` + ExtraProperties sdk.Properties `sumeru:"string=Extra Properties"` } diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 0724ab1a..2d8e9269 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -34,6 +34,10 @@ + + diff --git a/addons/base/views/sys_property_definition_form_views.xml b/addons/base/views/sys_property_definition_form_views.xml new file mode 100644 index 00000000..4ed017f3 --- /dev/null +++ b/addons/base/views/sys_property_definition_form_views.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/addons/base/views/sys_property_definition_list_views.xml b/addons/base/views/sys_property_definition_list_views.xml new file mode 100644 index 00000000..b09f267f --- /dev/null +++ b/addons/base/views/sys_property_definition_list_views.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/contacts/manifest.json b/addons/contacts/manifest.json index cd9e4f37..7ae62d91 100644 --- a/addons/contacts/manifest.json +++ b/addons/contacts/manifest.json @@ -13,6 +13,7 @@ "views/actions.xml", "views/core_partner_form_views.xml", "views/core_partner_form_inherit_views.xml", + "views/core_partner_properties_inherit_views.xml", "views/core_partner_list_views.xml", "views/core_partner_kanban_views.xml", "views/menus.xml" diff --git a/addons/contacts/views/actions.xml b/addons/contacts/views/actions.xml index 5d781f05..2f0dac7c 100644 --- a/addons/contacts/views/actions.xml +++ b/addons/contacts/views/actions.xml @@ -2,6 +2,9 @@ +

diff --git a/addons/contacts/views/core_partner_properties_inherit_views.xml b/addons/contacts/views/core_partner_properties_inherit_views.xml new file mode 100644 index 00000000..1a9fc362 --- /dev/null +++ b/addons/contacts/views/core_partner_properties_inherit_views.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/addons/platform/init.go b/addons/platform/init.go index d322426a..4877366f 100644 --- a/addons/platform/init.go +++ b/addons/platform/init.go @@ -4,5 +4,6 @@ package platform import ( _ "sumeru/addons/base" + _ "sumeru/addons/currency" _ "sumeru/addons/platform/models" ) diff --git a/addons/platform/manifest.json b/addons/platform/manifest.json index ebfba54d..77bfbeec 100644 --- a/addons/platform/manifest.json +++ b/addons/platform/manifest.json @@ -4,7 +4,7 @@ "version": "1.0.0", "author": "Sumeru", "description": "Attachments, sequences, system parameters, reports, and bulk import templates.", - "depends": ["base"], + "depends": ["base", "currency"], "auto_install": true, "application": false, "data": [ @@ -24,6 +24,9 @@ "views/sys_bulk_import_form_views.xml", "views/sys_import_template_list_views.xml", "views/sys_import_template_form_views.xml", + "views/platform_pricing_sample_actions.xml", + "views/platform_pricing_sample_list_views.xml", + "views/platform_pricing_sample_form_views.xml", "views/menus.xml" ] } diff --git a/addons/platform/models/platform_pricing_sample.go b/addons/platform/models/platform_pricing_sample.go new file mode 100644 index 00000000..df7615f2 --- /dev/null +++ b/addons/platform/models/platform_pricing_sample.go @@ -0,0 +1,14 @@ +package models + +import ( + "sumeru/core/sdk" +) + +// PlatformPricingSample is a reference row for SUM-PLAT-18 monetary fields and conversion. +type PlatformPricingSample struct { + sdk.Model `sumeru:"model=platform.pricing.sample"` + + Name sdk.String `sumeru:"required,string=Label"` + CurrencyID sdk.Many2One[CoreCurrency] `sumeru:"required,index,string=Currency"` + Amount sdk.Money `sumeru:"required,string=Amount,currency=currency_id"` +} diff --git a/addons/platform/models/zmodels.go b/addons/platform/models/zmodels.go index 8f73fac2..0f3436d4 100644 --- a/addons/platform/models/zmodels.go +++ b/addons/platform/models/zmodels.go @@ -6,6 +6,7 @@ import "sumeru/core/sdk" func init() { sdk.MustRegister("platform", + &PlatformPricingSample{}, &SysAttachment{}, &SysBulkImport{}, &SysConfigParameter{}, diff --git a/addons/platform/models/zrefs.go b/addons/platform/models/zrefs.go index bcec8b9c..88186fc1 100644 --- a/addons/platform/models/zrefs.go +++ b/addons/platform/models/zrefs.go @@ -4,11 +4,15 @@ package models import ( basemodels "sumeru/addons/base/models" + currencymodels "sumeru/addons/currency/models" ) // CoreCompany → core.company type CoreCompany = basemodels.CoreCompany +// CoreCurrency → core.currency +type CoreCurrency = currencymodels.CoreCurrency + // CoreUser → core.user type CoreUser = basemodels.CoreUser diff --git a/addons/platform/security/sys.access.csv b/addons/platform/security/sys.access.csv index adf7c7eb..65d1a3e8 100644 --- a/addons/platform/security/sys.access.csv +++ b/addons/platform/security/sys.access.csv @@ -1,2 +1,3 @@ id,name,model,group_id,perm_read,perm_write,perm_create,perm_unlink access_sys_import_template_user,access_sys_import_template_user,sys.import.template,base.group_user,1,1,1,1 +access_platform_pricing_sample_user,access_platform_pricing_sample_user,platform.pricing.sample,base.group_user,1,1,1,1 diff --git a/addons/platform/views/menus.xml b/addons/platform/views/menus.xml index 10d868ee..b3eef696 100644 --- a/addons/platform/views/menus.xml +++ b/addons/platform/views/menus.xml @@ -15,5 +15,8 @@ + diff --git a/addons/platform/views/platform_pricing_sample_actions.xml b/addons/platform/views/platform_pricing_sample_actions.xml new file mode 100644 index 00000000..146852a7 --- /dev/null +++ b/addons/platform/views/platform_pricing_sample_actions.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/addons/platform/views/platform_pricing_sample_form_views.xml b/addons/platform/views/platform_pricing_sample_form_views.xml new file mode 100644 index 00000000..156c9ad8 --- /dev/null +++ b/addons/platform/views/platform_pricing_sample_form_views.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/addons/platform/views/platform_pricing_sample_list_views.xml b/addons/platform/views/platform_pricing_sample_list_views.xml new file mode 100644 index 00000000..3006dd7a --- /dev/null +++ b/addons/platform/views/platform_pricing_sample_list_views.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/core/engine/assets/css/sumeru-views.css b/core/engine/assets/css/sumeru-views.css index 9547abf6..29623784 100644 --- a/core/engine/assets/css/sumeru-views.css +++ b/core/engine/assets/css/sumeru-views.css @@ -3363,3 +3363,36 @@ body.sum-debug--field-inspector.sum-debug--active .sum-field-widget[data-sum-deb max-height: 60vh; overflow: auto; } + +.sum-properties { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sum-properties-row { + display: grid; + grid-template-columns: minmax(8rem, 30%) 1fr; + gap: 0.5rem 1rem; + align-items: center; +} + +.sum-properties-empty { + color: var(--sum-muted); + font-size: 0.875rem; +} + +.sum-activity-type { + margin: 0.75rem 0 0.35rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--sum-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sum-activity-user h3 { + margin: 0.35rem 0; + font-size: 0.875rem; + font-weight: 600; +} diff --git a/core/engine/swcmeta/arch.go b/core/engine/swcmeta/arch.go index 8fa8fa40..ebfcb459 100644 --- a/core/engine/swcmeta/arch.go +++ b/core/engine/swcmeta/arch.go @@ -174,6 +174,17 @@ func enrichField(model string, f ArchField) ArchField { if f.Widget == "" && fd.Widget != "" { f.Widget = fd.Widget } + if fd.Widget == "monetary" || fd.Currency != "" { + if f.Widget == "" { + f.Widget = "monetary" + } + if f.Options == nil { + f.Options = map[string]string{} + } + if fd.Currency != "" { + f.Options["currency_field"] = fd.Currency + } + } if f.Options == nil && fd.Domain != "" { f.Options = map[string]string{"domain": fd.Domain} } diff --git a/core/swc/src/shell/internal-messages-panel.ts b/core/swc/src/shell/internal-messages-panel.ts index 76dd52e1..4fae841a 100644 --- a/core/swc/src/shell/internal-messages-panel.ts +++ b/core/swc/src/shell/internal-messages-panel.ts @@ -3,7 +3,7 @@ import { html } from "../template/html.js"; import { SWC_API_BASE, VIEW_FORM } from "../constants/routes.js"; import { SwcError } from "../runtime/error.js"; import { inputValueFromEvent } from "../widgets/field-events.js"; -import { ACTIVITY_CONTEXT, type ActivityContextPayload } from "./activity-context.js"; +import { ACTIVITY_CONTEXT, activityBoardHref, type ActivityContextPayload } from "./activity-context.js"; import { setMessagesUnreadBadge } from "./activity-panel.js"; import { rankByFuzzy } from "../util/fuzzy.js"; import { formatRelativeTime } from "../util/format-relative-time.js"; @@ -73,6 +73,7 @@ export class InternalMessagesPanel extends SwcComponent { private banner = ""; private linkModel = ""; private linkRecordId = 0; + private activityCtx: ActivityContextPayload = { model: "", recordId: 0, recordMessagesEligible: false, recordLogEligible: false }; private pickerIndex = 0; private pickerOpen = false; private scrollThreadEnd = false; @@ -92,6 +93,7 @@ export class InternalMessagesPanel extends SwcComponent { } this.unsubActivity = this.env.services.bus.subscribe(ACTIVITY_CONTEXT, (payload) => { const ctx = payload as ActivityContextPayload; + this.activityCtx = ctx; if (ctx.recordId > 0 && ctx.model) { this.linkModel = ctx.model; this.linkRecordId = ctx.recordId; @@ -99,6 +101,7 @@ export class InternalMessagesPanel extends SwcComponent { this.linkModel = ""; this.linkRecordId = 0; } + this.rerender(); }); void this.bootstrapPanel(); } @@ -416,8 +419,11 @@ export class InternalMessagesPanel extends SwcComponent { } private composerHint(): ReturnType { - if (this.linkModel && this.linkRecordId > 0) { - return html`

Linked to open record · Enter to send

`; + if (this.activityCtx.recordId > 0 && this.activityCtx.model) { + return html`

+ Linked to open record · Enter to send · + Activities for this record +

`; } return html`

Enter to send · Shift+Enter for new line

`; } diff --git a/core/swc/src/views/activity/ActivityView.ts b/core/swc/src/views/activity/ActivityView.ts index c6869be4..611ef86f 100644 --- a/core/swc/src/views/activity/ActivityView.ts +++ b/core/swc/src/views/activity/ActivityView.ts @@ -117,15 +117,37 @@ export class ActivityView extends CollectionView { return html`

No activities

`; } return html`
- ${COLUMNS.map((column) => html`
+ ${COLUMNS.map((column) => { + const byUser = grouped.get(column.key) ?? new Map(); + const byType = new Map[]>>(); + for (const [user, rows] of byUser.entries()) { + for (const row of rows) { + const typeName = this.typeName(row); + let users = byType.get(typeName); + if (!users) { + users = new Map(); + byType.set(typeName, users); + } + const list = users.get(user) ?? []; + list.push(row); + users.set(user, list); + } + } + return html`

${column.label}

- ${[...(grouped.get(column.key)?.entries() ?? [])].map( - ([user, rows]) => html`
-

${user}

- ${rows.map((row) => this.card(row))} + ${[...(byType.entries())].map( + ([typeName, users]) => html`
+

${typeName}

+ ${[...(users.entries())].map( + ([user, rows]) => html`
+

${user}

+ ${rows.map((row) => this.card(row))} +
`, + )}
`, )} -
`)} +
`; + })}
`; } diff --git a/core/swc/src/views/shared/field-display.ts b/core/swc/src/views/shared/field-display.ts index 89679299..2b2abc25 100644 --- a/core/swc/src/views/shared/field-display.ts +++ b/core/swc/src/views/shared/field-display.ts @@ -29,6 +29,17 @@ export function formatFieldValue(row: Record, field: SwcArchFie return match?.[1] ?? key; } + if (field.widget === "monetary") { + const amount = row[field.name]; + if (amount == null || amount === "") return ""; + const currencyField = field.options?.currency_field?.trim(); + const symbol = + field.options?.currency_symbol?.trim() || + (currencyField ? String(row[`${currencyField}_name`] ?? row[`${currencyField}_symbol`] ?? "") : ""); + const text = String(amount); + return symbol ? `${symbol} ${text}` : text; + } + const raw = row[`${field.name}_name`] ?? row[field.name]; if (raw == null || raw === false) return ""; return String(raw); diff --git a/core/swc/src/widgets/PropertiesField.ts b/core/swc/src/widgets/PropertiesField.ts index a9e0a1d8..cabd6a98 100644 --- a/core/swc/src/widgets/PropertiesField.ts +++ b/core/swc/src/widgets/PropertiesField.ts @@ -101,6 +101,12 @@ export class PropertiesField extends SwcComponent { this.setKey(def.key, target.checked); }} />`; } + if (def.value_type === "float") { + return html` { + const target = event.target as HTMLInputElement; + this.setKey(def.key, target.value === "" ? null : Number(target.value)); + }} />`; + } if (def.value_type === "integer" || def.value_type === "many2one") { return html` { const target = event.target as HTMLInputElement; diff --git a/core/swc/src/widgets/extra-fields.ts b/core/swc/src/widgets/extra-fields.ts index 7913f032..e15e0288 100644 --- a/core/swc/src/widgets/extra-fields.ts +++ b/core/swc/src/widgets/extra-fields.ts @@ -6,10 +6,23 @@ import type { FieldWidgetProps } from "./field-props.js"; import { stringFromUnknown } from "./field-value.js"; import { isFieldReadonly } from "../model/modifiers.js"; +function monetarySymbol(field: FieldWidgetProps["field"], record: FieldWidgetProps["record"]): string { + const staticSymbol = field.options?.currency_symbol?.trim(); + if (staticSymbol) return staticSymbol; + const currencyField = field.options?.currency_field?.trim(); + if (currencyField) { + const named = record.get(`${currencyField}_name`); + if (named != null && named !== "") return String(named); + const sym = record.get(`${currencyField}_symbol`); + if (sym != null && sym !== "") return String(sym); + } + return "¤"; +} + export class MonetaryField extends DefaultField { override template() { const { field, record, readonly } = this.props; - const symbol = field.options?.currency_symbol ?? "¤"; + const symbol = monetarySymbol(field, record); const fieldValue = stringFromUnknown(record.get(field.name)); if (isFieldReadonly(field, record, readonly)) { return renderFieldShell( diff --git a/core/swc/tests/views/activity-board.test.ts b/core/swc/tests/views/activity-board.test.ts index 8086ec7d..891b56c7 100644 --- a/core/swc/tests/views/activity-board.test.ts +++ b/core/swc/tests/views/activity-board.test.ts @@ -63,6 +63,40 @@ describe("activity board actions", () => { view.destroy(); }); + it("groups cards by activity type within each column", () => { + const view = new ActivityView( + { + payload: viewPayload({ type: "activity", fields: [{ name: "summary" }] }, [ + { + id: 1, + summary: "A", + date_deadline: "", + user_id_name: "Ada", + activity_type_id_name: "Call", + model: "crm.lead", + res_id: 1, + }, + { + id: 2, + summary: "B", + date_deadline: "", + user_id_name: "Ada", + activity_type_id_name: "Email", + model: "crm.lead", + res_id: 2, + }, + ]), + }, + collectionEnv(), + ); + view.callSetup(); + const el = view.render(); + expect(el.querySelectorAll(".sum-activity-type").length).toBe(2); + expect(el.textContent).toContain("Call"); + expect(el.textContent).toContain("Email"); + view.destroy(); + }); + it("opens the unfiltered board when no record is selected", () => { expect(activityBoardHref({ model: "", recordId: 0, recordMessagesEligible: false, recordLogEligible: false })).toBe( "/web?action=mail.action_mail_activity&view_type=activity", diff --git a/core/swc/tests/views/form-widgets.test.ts b/core/swc/tests/views/form-widgets.test.ts index 55b1614b..ad4a3b56 100644 --- a/core/swc/tests/views/form-widgets.test.ts +++ b/core/swc/tests/views/form-widgets.test.ts @@ -129,6 +129,16 @@ describe("form field widgets", () => { .textContent, ).toContain("$ 10"); + const monetaryCur = new SwcRecord("m", 1, { amount: "99", currency_id_name: "USD" }); + expect( + renderField( + env, + field({ name: "amount", widget: "monetary", options: { currency_field: "currency_id" } }), + monetaryCur, + true, + ).textContent, + ).toContain("USD 99"); + const htmlRec = new SwcRecord("m", 1, { body: "Hi" }); expect(renderField(env, field({ name: "body", widget: "html" }), htmlRec, true).textContent).toContain("Hi"); diff --git a/core/swc/tests/views/properties-field.test.ts b/core/swc/tests/views/properties-field.test.ts index 344301c5..da99ebcc 100644 --- a/core/swc/tests/views/properties-field.test.ts +++ b/core/swc/tests/views/properties-field.test.ts @@ -32,6 +32,27 @@ describe("PropertiesField", () => { expect(el.querySelectorAll("input").length).toBe(2); }); + it("renders selection and float inputs", () => { + const field: SwcArchField = { + name: "props", + string: "Properties", + type: "properties", + widget: "properties", + options: { + schema: JSON.stringify([ + { key: "tier", label: "Tier", value_type: "selection", selection: "gold:Gold,silver:Silver" }, + { key: "score", label: "Score", value_type: "float" }, + ]), + }, + }; + const record = new SwcRecord("core.partner", 1, { props: { tier: "gold", score: 9.5 } }); + const comp = new PropertiesField({ field, record, readonly: false }, env()); + comp.callSetup(); + const el = comp.render(); + expect(el.querySelector("select")).toBeTruthy(); + expect(el.querySelector('input[type="number"][step="any"]')).toBeTruthy(); + }); + it("shows an empty state when no definitions exist", () => { const field: SwcArchField = { name: "props", diff --git a/test/addons/base/models/partner_properties_field_test.go b/test/addons/base/models/partner_properties_field_test.go new file mode 100644 index 00000000..a2141038 --- /dev/null +++ b/test/addons/base/models/partner_properties_field_test.go @@ -0,0 +1,23 @@ +package models_test + +import ( + "testing" + + _ "sumeru/addons/base" + "sumeru/core/modelreg" + "sumeru/core/orm" +) + +func TestCorePartnerExtraPropertiesField(t *testing.T) { + _ = modelreg.ActivateAll([]string{"base"}) + inst, ok := orm.Registry["core.partner"] + if !ok { + t.Fatal("core.partner not registered") + } + for _, f := range inst.Fields() { + if f.Name == "extra_properties" && f.Type == orm.Properties { + return + } + } + t.Fatal("core.partner.extra_properties properties field missing") +} diff --git a/test/addons/platform/models/pricing_sample_test.go b/test/addons/platform/models/pricing_sample_test.go new file mode 100644 index 00000000..2e017165 --- /dev/null +++ b/test/addons/platform/models/pricing_sample_test.go @@ -0,0 +1,26 @@ +package models_test + +import ( + "testing" + + _ "sumeru/addons/currency" + _ "sumeru/addons/platform" + "sumeru/core/modelreg" + "sumeru/core/orm" +) + +func TestPlatformPricingSampleMoneyField(t *testing.T) { + _ = modelreg.ActivateAll([]string{"base", "currency", "platform"}) + inst, ok := orm.Registry["platform.pricing.sample"] + if !ok { + t.Fatal("platform.pricing.sample not registered") + } + fields := map[string]orm.FieldDefinition{} + for _, f := range inst.Fields() { + fields[f.Name] = f + } + amount, ok := fields["amount"] + if !ok || amount.Widget != "monetary" || amount.Currency != "currency_id" { + t.Fatalf("amount field = %+v, want monetary with currency_id", amount) + } +} diff --git a/test/core/engine/swcmeta/arch_test.go b/test/core/engine/swcmeta/arch_test.go index 41a85307..eab3eb0b 100644 --- a/test/core/engine/swcmeta/arch_test.go +++ b/test/core/engine/swcmeta/arch_test.go @@ -169,6 +169,31 @@ func TestSerializeFieldListSubview(t *testing.T) { } } +func TestEnrichFieldMonetaryCurrencyOption(t *testing.T) { + const model = "test.swcmeta.money" + orm.Registry[model] = stubMoneyModel{} + t.Cleanup(func() { delete(orm.Registry, model) }) + + got := swcmeta.EnrichFieldForTest(model, swcmeta.ArchField{Name: "amount"}) + if got.Widget != "monetary" { + t.Fatalf("widget = %q, want monetary", got.Widget) + } + if got.Options["currency_field"] != "currency_id" { + t.Fatalf("currency_field = %q", got.Options["currency_field"]) + } +} + +type stubMoneyModel struct{} + +func (stubMoneyModel) Fields() []orm.FieldDefinition { + return []orm.FieldDefinition{ + {Name: "currency_id", Type: orm.Many2One, Relation: "core.currency"}, + {Name: "amount", Type: orm.Numeric, Widget: "monetary", Currency: "currency_id"}, + } +} + +func (stubMoneyModel) ModelName() string { return "test.swcmeta.money" } + func TestSerializeModifierExpressions(t *testing.T) { fields := swcmeta.SerializeFieldsForTest(context.Background(), []parser.Field{ {Name: "amount", Invisible: "state == 'done'", Readonly: "true"},