From 2579dfebb0b48c1d827efd32b349f1672222d76d Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Mon, 12 Oct 2026 23:51:45 +0530 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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"},