From a75bfcd85b30bf0aaac44957806407a7332f34e3 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sat, 3 Oct 2026 23:51:58 +0530 Subject: [PATCH 01/15] feat(platform): filestore-backed attachments and content handler ACL Wire filestore_path, CreateBinaryAttachment, and CanReadAttachmentContent so /web/content and binary fields use the filestore with safe fallbacks. --- addons/base/models/sys_attachment.go | 1 + core/orm/attachment_access.go | 59 +++++++++++++ core/orm/attachment_binary.go | 81 +++++++++++++++++ core/report/document_render.go | 25 ++---- core/server/config/config.go | 3 + core/server/config/const.go | 1 + core/server/config/paths.go | 5 +- core/server/run.go | 3 + core/server/web/content_disposition.go | 9 ++ core/server/web/content_handler.go | 103 +++++++++++++++++----- core/swc/src/widgets/extra-fields.ts | 9 +- sumeru.conf.example | 1 + test/core/orm/attachment_access_test.go | 29 ++++++ test/core/orm/attachment_binary_test.go | 26 ++++++ test/core/server/web/content_path_test.go | 64 ++++++++++++++ 15 files changed, 375 insertions(+), 44 deletions(-) create mode 100644 core/orm/attachment_access.go create mode 100644 core/orm/attachment_binary.go create mode 100644 test/core/orm/attachment_access_test.go create mode 100644 test/core/orm/attachment_binary_test.go create mode 100644 test/core/server/web/content_path_test.go diff --git a/addons/base/models/sys_attachment.go b/addons/base/models/sys_attachment.go index b3a2f6e9..2cce1db1 100644 --- a/addons/base/models/sys_attachment.go +++ b/addons/base/models/sys_attachment.go @@ -10,6 +10,7 @@ type SysAttachment struct { Name sdk.String `sumeru:"required,string=Name"` ResModel sdk.String `sumeru:"index,column=model,string=Model"` ResID sdk.Integer `sumeru:"index,string=Record"` + ResField sdk.String `sumeru:"index,string=Field"` Mimetype sdk.String `sumeru:"string=MIME Type"` FileSize sdk.Integer `sumeru:"string=Size"` Datas sdk.Text `sumeru:"string=Data"` diff --git a/core/orm/attachment_access.go b/core/orm/attachment_access.go new file mode 100644 index 00000000..fbd752d9 --- /dev/null +++ b/core/orm/attachment_access.go @@ -0,0 +1,59 @@ +package orm + +import ( + "context" + "fmt" + "strings" +) + +// CanReadAttachmentContentForTest exposes attachment ACL for external tests. +func CanReadAttachmentContentForTest(ctx context.Context, uid int, att map[string]interface{}) error { + return CanReadAttachmentContent(ctx, uid, att) +} + +// CanReadAttachmentContent checks sys.attachment read plus linked record read when set. +func CanReadAttachmentContent(ctx context.Context, uid int, att map[string]interface{}) error { + if err := CheckModelAccess(ctx, uid, "sys.attachment", "read"); err != nil { + return err + } + resModel := strings.TrimSpace(AsString(att["model"])) + resID, _ := CoerceInt64(att["res_id"]) + if resModel == "" || resID <= 0 { + return nil + } + if _, ok := Registry[resModel]; !ok { + return fmt.Errorf("unknown linked model %q", resModel) + } + if err := CheckModelAccess(ctx, uid, resModel, "read"); err != nil { + return err + } + row, err := SearchOne(ctx, resModel, map[string]interface{}{"id": resID}) + if err != nil || len(row) == 0 { + return fmt.Errorf("linked record not found") + } + return nil +} + +// ResolveContentAttachment loads sys.attachment for content serving. +// attachmentID > 0 loads by primary key; otherwise model+resField+resID must identify a row. +// ResolveContentAttachmentForTest exposes attachment lookup for external tests. +func ResolveContentAttachmentForTest(ctx context.Context, attachmentID int, resModel, resField string, resID int) (map[string]interface{}, error) { + return ResolveContentAttachment(ctx, attachmentID, resModel, resField, resID) +} + +func ResolveContentAttachment(ctx context.Context, attachmentID int, resModel, resField string, resID int) (map[string]interface{}, error) { + if attachmentID > 0 { + return SearchOne(ctx, "sys.attachment", map[string]interface{}{"id": attachmentID}) + } + resModel = strings.TrimSpace(resModel) + resField = strings.TrimSpace(resField) + if resModel == "" || resField == "" || resID <= 0 { + return nil, fmt.Errorf("attachment not found") + } + domain := map[string]interface{}{ + "model": resModel, + "res_id": resID, + "res_field": resField, + } + return SearchOne(ctx, "sys.attachment", domain) +} diff --git a/core/orm/attachment_binary.go b/core/orm/attachment_binary.go new file mode 100644 index 00000000..770b49ce --- /dev/null +++ b/core/orm/attachment_binary.go @@ -0,0 +1,81 @@ +package orm + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" +) + +// AttachmentScanner runs before bytes are written to the filestore (optional AV integration). +type AttachmentScanner func(ctx context.Context, name, mime string, body io.Reader) error + +var attachmentScanner AttachmentScanner + +// SetAttachmentScanner registers a virus-scan hook; nil clears it. +func SetAttachmentScanner(fn AttachmentScanner) { + attachmentScanner = fn +} + +// CreateBinaryAttachmentInput stores blob in filestore and creates sys.attachment. +type CreateBinaryAttachmentInput struct { + Name string + ResModel string + ResField string + ResID int + Data []byte + Mimetype string + CompanyID int64 +} + +// CreateBinaryAttachment validates MIME, optional scan, filestore write, ORM create. +func CreateBinaryAttachment(ctx context.Context, in CreateBinaryAttachmentInput) (int, error) { + if len(in.Data) == 0 { + return 0, fmt.Errorf("attachment: empty data") + } + name := strings.TrimSpace(in.Name) + if name == "" { + name = "file" + } + mime := normalizeAttachmentMIME(strings.TrimSpace(in.Mimetype), in.Data) + if err := ValidateAttachmentMIME(mime, in.Data); err != nil { + return 0, err + } + if attachmentScanner != nil { + if err := attachmentScanner(ctx, name, mime, bytes.NewReader(in.Data)); err != nil { + return 0, fmt.Errorf("attachment scan: %w", err) + } + } + storeKey := fmt.Sprintf("%s_%d_%s", strings.TrimSpace(in.ResModel), in.ResID, time.Now().UTC().Format("20060102T150405.000")) + storeFname, size, err := StoreAttachment(ctx, storeKey, in.Data) + if err != nil { + return 0, err + } + vals := map[string]interface{}{ + "name": name, + "mimetype": mime, + "file_size": size, + "store_fname": storeFname, + } + if m := strings.TrimSpace(in.ResModel); m != "" { + vals["model"] = m + } + if in.ResID > 0 { + vals["res_id"] = in.ResID + } + if f := strings.TrimSpace(in.ResField); f != "" { + vals["res_field"] = f + } + if in.CompanyID > 0 { + vals["company_id"] = in.CompanyID + } else if cid := CompanyIDFromContext(ctx); cid > 0 { + vals["company_id"] = cid + } + inst, ok := Registry["sys.attachment"] + if !ok { + return 0, fmt.Errorf("sys.attachment not registered") + } + return Create(ctx, inst, vals) +} diff --git a/core/report/document_render.go b/core/report/document_render.go index 6d832cd6..2634362b 100644 --- a/core/report/document_render.go +++ b/core/report/document_render.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "sumeru/core/orm" ) @@ -119,21 +118,11 @@ func StoreReportPDFAttachment(ctx context.Context, modelName string, recordID in if name == "" { name = "report.pdf" } - storeKey := fmt.Sprintf("%s_%d_%s", modelName, recordID, time.Now().UTC().Format("20060102T150405")) - storeFname, size, err := orm.StoreAttachment(ctx, storeKey, pdf) - if err != nil { - return 0, err - } - vals := map[string]interface{}{ - "name": name, - "model": modelName, - "res_id": recordID, - "mimetype": "application/pdf", - "file_size": size, - "store_fname": storeFname, - } - if cid := orm.CompanyIDFromContext(ctx); cid > 0 { - vals["company_id"] = cid - } - return orm.Create(ctx, orm.Registry["sys.attachment"], vals) + return orm.CreateBinaryAttachment(ctx, orm.CreateBinaryAttachmentInput{ + Name: name, + ResModel: modelName, + ResID: recordID, + Data: pdf, + Mimetype: "application/pdf", + }) } diff --git a/core/server/config/config.go b/core/server/config/config.go index 6272ed9d..7164309d 100644 --- a/core/server/config/config.go +++ b/core/server/config/config.go @@ -25,6 +25,7 @@ type Config struct { 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 @@ -127,6 +128,8 @@ func LoadConfig(path string) error { AppConfig.BrandCSS = val case keyLogoPath: AppConfig.LogoPath = val + case keyFilestorePath: + AppConfig.FilestorePath = val case keyCompanyDisplayName: AppConfig.CompanyDisplayName = val case keyUserDisplayName: diff --git a/core/server/config/const.go b/core/server/config/const.go index 85f58dcb..3cb95472 100644 --- a/core/server/config/const.go +++ b/core/server/config/const.go @@ -23,6 +23,7 @@ const ( keyTemplatesPath = "templates_path" keyBrandCSS = "brand_css" keyLogoPath = "logo_path" + keyFilestorePath = "filestore_path" keyCompanyDisplayName = "company_display_name" keyUserDisplayName = "user_display_name" keyLogFile = "log_file" diff --git a/core/server/config/paths.go b/core/server/config/paths.go index 497593b1..2f9c350d 100644 --- a/core/server/config/paths.go +++ b/core/server/config/paths.go @@ -51,7 +51,10 @@ func AbsPaths() error { func resolveConfigPathFields(sumeruRepo string) error { var err error - for _, ptr := range []*string{&AppConfig.AssetsPath, &AppConfig.TemplatesPath, &AppConfig.BrandCSS, &AppConfig.LogoPath, &AppConfig.LogFile} { + if strings.TrimSpace(AppConfig.FilestorePath) == "" { + AppConfig.FilestorePath = "data/filestore" + } + for _, ptr := range []*string{&AppConfig.AssetsPath, &AppConfig.TemplatesPath, &AppConfig.BrandCSS, &AppConfig.LogoPath, &AppConfig.LogFile, &AppConfig.FilestorePath} { if strings.TrimSpace(*ptr) == "" { continue } diff --git a/core/server/run.go b/core/server/run.go index 5cb0d234..52a45bce 100644 --- a/core/server/run.go +++ b/core/server/run.go @@ -50,6 +50,9 @@ func Run() { if err := AbsPaths(); err != nil { applog.BootstrapFatal("Resolve paths: %v", err) } + if err := orm.InitFilestore(config.AppConfig.FilestorePath); err != nil { + applog.BootstrapFatal("Filestore: %v", err) + } if err := applog.SetupFromConfig(&config.AppConfig); err != nil { applog.BootstrapFatal("Logging: %v", err) diff --git a/core/server/web/content_disposition.go b/core/server/web/content_disposition.go index c4211798..a7a938e5 100644 --- a/core/server/web/content_disposition.go +++ b/core/server/web/content_disposition.go @@ -14,3 +14,12 @@ func safeContentDispositionFilename(name string) string { } return mime.FormatMediaType("attachment", map[string]string{"filename": name}) } + +func safeContentDispositionInline(name string) string { + name = strings.TrimSpace(name) + name = strings.NewReplacer("\r", "", "\n", "", `"`, "", `\`, "").Replace(name) + if name == "" { + name = "preview" + } + return mime.FormatMediaType("inline", map[string]string{"filename": name}) +} diff --git a/core/server/web/content_handler.go b/core/server/web/content_handler.go index f387c4c9..7fafd390 100644 --- a/core/server/web/content_handler.go +++ b/core/server/web/content_handler.go @@ -1,6 +1,8 @@ package web import ( + "bytes" + "encoding/base64" "io" "net/http" "strconv" @@ -11,38 +13,57 @@ import ( const contentRoutePrefix = "/web/content/" -// ContentHandler GET /web/content/{field}/{id} — authenticated attachment download. +// ContentHandler GET /web/content/{id} or /web/content/{model}/{field}/{resId} func ContentHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return } - _, recordID, ok := parseContentPath(r.URL.Path) + spec, ok := parseContentPath(r.URL.Path) if !ok { http.NotFound(w, r) return } uid := AuthenticatedUserID(r) ctx := r.Context() - if err := orm.CheckModelAccess(ctx, uid, "sys.attachment", "read"); err != nil { - http.Error(w, "forbidden", http.StatusForbidden) - return + + var rec map[string]interface{} + var err error + if spec.attachmentID > 0 { + rec, err = orm.ResolveContentAttachment(ctx, spec.attachmentID, "", "", 0) + } else { + rec, err = orm.ResolveContentAttachment(ctx, 0, spec.resModel, spec.resField, spec.resID) } - rec, err := orm.SearchOne(ctx, "sys.attachment", map[string]interface{}{"id": recordID}) if err != nil || len(rec) == 0 { http.NotFound(w, r) return } - storeFname := orm.AsString(rec["store_fname"]) - if storeFname == "" { - http.NotFound(w, r) + if err := orm.CanReadAttachmentContent(ctx, int(uid), rec); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) return } - reader, err := orm.OpenAttachment(ctx, storeFname) - if err != nil { - http.Error(w, "not found", http.StatusNotFound) - return + + var reader io.ReadCloser + storeFname := strings.TrimSpace(orm.AsString(rec["store_fname"])) + if storeFname != "" { + reader, err = orm.OpenAttachment(ctx, storeFname) + if err != nil { + http.NotFound(w, r) + return + } + defer reader.Close() + } else { + raw := orm.AsString(rec["datas"]) + data, decErr := base64.StdEncoding.DecodeString(raw) + if decErr != nil { + data = []byte(raw) + } + if len(data) == 0 { + http.NotFound(w, r) + return + } + reader = io.NopCloser(bytes.NewReader(data)) + defer reader.Close() } - defer reader.Close() mimeType := strings.TrimSpace(orm.AsString(rec["mimetype"])) name := strings.TrimSpace(orm.AsString(rec["name"])) @@ -52,23 +73,59 @@ func ContentHandler(w http.ResponseWriter, r *http.Request) { if mimeType == "" { mimeType = "application/octet-stream" } + inline := strings.TrimSpace(r.URL.Query().Get("download")) == "0" || + strings.EqualFold(r.URL.Query().Get("download"), "false") w.Header().Set("Content-Type", mimeType) w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Content-Disposition", safeContentDispositionFilename(name)) + if inline && contentInlineAllowed(mimeType) { + w.Header().Set("Content-Disposition", safeContentDispositionInline(name)) + } else { + w.Header().Set("Content-Disposition", safeContentDispositionFilename(name)) + } _, _ = io.Copy(w, reader) } -func parseContentPath(path string) (field string, recordID int, ok bool) { +type contentPathSpec struct { + attachmentID int + resModel string + resField string + resID int +} + +func parseContentPath(path string) (contentPathSpec, bool) { path = strings.TrimPrefix(path, contentRoutePrefix) path = strings.Trim(path, "/") + if path == "" { + return contentPathSpec{}, false + } parts := strings.Split(path, "/") - if len(parts) != 2 { - return "", 0, false + switch len(parts) { + case 1: + id, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil || id <= 0 { + return contentPathSpec{}, false + } + return contentPathSpec{attachmentID: id}, true + case 2: + // Legacy: field + parent record id requires ?model= + return contentPathSpec{}, false + case 3: + resModel := strings.TrimSpace(parts[0]) + resField := strings.TrimSpace(parts[1]) + resID, err := strconv.Atoi(strings.TrimSpace(parts[2])) + if resModel == "" || resField == "" || err != nil || resID <= 0 { + return contentPathSpec{}, false + } + return contentPathSpec{resModel: resModel, resField: resField, resID: resID}, true + default: + return contentPathSpec{}, false } - field = strings.TrimSpace(parts[0]) - id, err := strconv.Atoi(strings.TrimSpace(parts[1])) - if field == "" || err != nil || id <= 0 { - return "", 0, false +} + +func contentInlineAllowed(mimeType string) bool { + m := strings.ToLower(strings.TrimSpace(mimeType)) + if strings.HasPrefix(m, "image/") { + return true } - return field, id, true + return m == "application/pdf" } diff --git a/core/swc/src/widgets/extra-fields.ts b/core/swc/src/widgets/extra-fields.ts index 4f467239..7913f032 100644 --- a/core/swc/src/widgets/extra-fields.ts +++ b/core/swc/src/widgets/extra-fields.ts @@ -36,11 +36,16 @@ export class HtmlField extends DefaultField { export class BinaryField extends SwcComponent { override template() { - const { field, record } = this.props; + const { field, record, modelName } = this.props; const name = stringFromUnknown(record.get(`${field.name}_name`) ?? record.get(field.name) ?? "Download"); + const model = modelName ?? record.model ?? ""; + const href = + model && record.id > 0 + ? `/web/content/${encodeURIComponent(model)}/${encodeURIComponent(field.name)}/${record.id}` + : "#"; return renderFieldShell( field, - html`${name}`, + html`${name}`, shellOptions(record, { labelFor: false }), ); } diff --git a/sumeru.conf.example b/sumeru.conf.example index e8e6ec48..74578183 100644 --- a/sumeru.conf.example +++ b/sumeru.conf.example @@ -46,6 +46,7 @@ log_rolling = false # assets_path = # templates_path = # brand_css = +# filestore_path = data/filestore # Attachment blobs (absolute or relative to this INI) # logo_path = # Host-level sign-in + shell logo when core.company login_logo is empty (served as /static/app-logo) # company_display_name = # Sign-in headline when no company name in the database yet # user_display_name = diff --git a/test/core/orm/attachment_access_test.go b/test/core/orm/attachment_access_test.go new file mode 100644 index 00000000..f6e353c0 --- /dev/null +++ b/test/core/orm/attachment_access_test.go @@ -0,0 +1,29 @@ +package orm_test + +import ( + "context" + "testing" + + "sumeru/core/orm" +) + +func TestResolveContentAttachmentInvalid(t *testing.T) { + _, err := orm.ResolveContentAttachmentForTest(context.Background(), 0, "", "", 0) + if err == nil { + t.Fatal("expected error") + } +} + +func TestResolveContentAttachmentByIDWithoutDB(t *testing.T) { + _, err := orm.ResolveContentAttachmentForTest(context.Background(), 99, "", "", 0) + if err == nil { + t.Fatal("expected error without db") + } +} + +func TestCanReadAttachmentContentNoAccess(t *testing.T) { + err := orm.CanReadAttachmentContentForTest(context.Background(), 0, map[string]interface{}{}) + if err == nil { + t.Fatal("expected access error") + } +} diff --git a/test/core/orm/attachment_binary_test.go b/test/core/orm/attachment_binary_test.go new file mode 100644 index 00000000..3943caa2 --- /dev/null +++ b/test/core/orm/attachment_binary_test.go @@ -0,0 +1,26 @@ +package orm_test + +import ( + "context" + "path/filepath" + "testing" + + "sumeru/core/orm" +) + +func TestCreateBinaryAttachmentRequiresModel(t *testing.T) { + if err := orm.InitFilestore(filepath.Join(t.TempDir(), "fs")); err != nil { + t.Fatal(err) + } + _, err := orm.CreateBinaryAttachment(context.Background(), orm.CreateBinaryAttachmentInput{ + Name: "x.txt", + Data: []byte("data"), + }) + if err == nil { + t.Fatal("expected error without sys.attachment registry") + } +} + +func TestSetAttachmentScannerNil(t *testing.T) { + orm.SetAttachmentScanner(nil) +} diff --git a/test/core/server/web/content_path_test.go b/test/core/server/web/content_path_test.go new file mode 100644 index 00000000..aa899480 --- /dev/null +++ b/test/core/server/web/content_path_test.go @@ -0,0 +1,64 @@ +package web_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sumeru/core/server/web" +) + +func TestParseContentPathAttachmentID(t *testing.T) { + _, id, ok := web.ParseContentPathForTest("42") + if !ok || id != 42 { + t.Fatalf("parse attachment id: ok=%v id=%d", ok, id) + } +} + +func TestParseContentPathModelField(t *testing.T) { + field, id, ok := web.ParseContentPathForTest("core.partner/name/7") + if !ok || field != "name" || id != 7 { + t.Fatalf("parse model field: ok=%v field=%q id=%d", ok, field, id) + } +} + +func TestContentHandlerModelFieldPath(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodGet, "/web/content/core.partner/name/1?download=0", nil) + rec := httptest.NewRecorder() + web.ContentHandlerForTest(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("unexpected success without db") + } +} + +func TestContentHandlerNotFound(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodGet, "/web/content/99999", nil) + rec := httptest.NewRecorder() + web.ContentHandlerForTest(rec, req) + if rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestSafeContentDispositionInline(t *testing.T) { + d := web.SafeContentDispositionInlineForTest("preview.pdf") + if d == "" || !strings.Contains(d, "inline") { + t.Fatalf("disposition=%q", d) + } +} + +func TestContentInlineAllowedPDF(t *testing.T) { + if !web.ContentInlineAllowedForTest("application/pdf") { + t.Fatal("pdf should allow inline") + } + if web.ContentInlineAllowedForTest("text/html") { + t.Fatal("html must not inline") + } +} From 1442574a33cac816bcf0cce48bcca4d51fd49053 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sat, 3 Oct 2026 23:52:06 +0530 Subject: [PATCH 02/15] feat(web): chatter file upload and attachment preview in SWC Add upload route and attachment JSON for chatter; render previews and multipart upload support in the client. --- addons/mail/mail.go | 55 +++++++++++++ core/engine/assets/css/sumeru-views.css | 61 ++++++++++++++ core/server/web/chatter_upload.go | 80 +++++++++++++++++++ core/server/web/routes_table.go | 6 ++ core/server/web/swc_chatter.go | 25 +++++- core/server/web/testexports.go | 47 +++++++++++ core/server/web/web_constants.go | 6 ++ core/swc/src/services/http.ts | 19 +++++ core/swc/src/views/chatter/ChatterPanel.ts | 74 +++++++++++++++-- .../swc/src/views/shared/AttachmentPreview.ts | 34 ++++++++ test/addons/mail/list_attachments_test.go | 19 +++++ 11 files changed, 415 insertions(+), 11 deletions(-) create mode 100644 core/server/web/chatter_upload.go create mode 100644 core/swc/src/views/shared/AttachmentPreview.ts create mode 100644 test/addons/mail/list_attachments_test.go diff --git a/addons/mail/mail.go b/addons/mail/mail.go index 596d6b23..eac06f22 100644 --- a/addons/mail/mail.go +++ b/addons/mail/mail.go @@ -205,6 +205,61 @@ func QueryActivityLog(ctx context.Context, limit int, ctxModel string, ctxID int return rowsFromSearchResults(records), nil } +// AttachmentRow is a record-linked file for chatter or document lists. +type AttachmentRow struct { + ID int64 + Name string + Mimetype string +} + +// ListAttachmentsForRecord returns sys.attachment rows linked to a business record. +func ListAttachmentsForRecord(ctx context.Context, model string, coreID int64, limit int) ([]AttachmentRow, error) { + model = strings.TrimSpace(model) + if model == "" || coreID <= 0 { + return nil, fmt.Errorf("model and id required") + } + if _, ok := orm.Registry[model]; !ok { + return nil, fmt.Errorf("unknown model %q", model) + } + uid := orm.SecurityUID(ctx) + if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil { + return nil, err + } + if _, err := orm.SearchOne(ctx, model, map[string]interface{}{"id": int(coreID)}); err != nil { + return nil, fmt.Errorf("record not found or access denied") + } + if err := orm.CheckModelAccess(ctx, uid, "sys.attachment", "read"); err != nil { + return nil, err + } + if limit <= 0 || limit > 200 { + limit = 50 + } + domain := [][]interface{}{ + {"model", "=", model}, + {"res_id", "=", int(coreID)}, + } + records, err := orm.SearchPage(ctx, "sys.attachment", domain, limit, 0, "create_date DESC") + if err != nil { + return nil, err + } + out := make([]AttachmentRow, 0, len(records)) + for _, rec := range records { + id, _ := orm.CoerceInt64(rec["id"]) + if id <= 0 { + continue + } + if err := orm.CanReadAttachmentContent(ctx, int(uid), rec); err != nil { + continue + } + out = append(out, AttachmentRow{ + ID: id, + Name: rowString(rec, "name"), + Mimetype: rowString(rec, "mimetype"), + }) + } + return out, nil +} + func rowsFromSearchResults(records []map[string]interface{}) []Row { out := make([]Row, 0, len(records)) for _, rec := range records { diff --git a/core/engine/assets/css/sumeru-views.css b/core/engine/assets/css/sumeru-views.css index b8c22753..e856057f 100644 --- a/core/engine/assets/css/sumeru-views.css +++ b/core/engine/assets/css/sumeru-views.css @@ -2905,6 +2905,67 @@ label.sum-form-label--hint { min-width: 5.5rem; } +.sum-chatter-upload { + display: block; + margin-bottom: var(--sum-space-sm); +} + +.sum-chatter-attachment-link { + background: none; + border: none; + padding: 0; + color: var(--sum-accent); + cursor: pointer; + font: inherit; + text-align: left; +} + +.sum-attachment-preview-backdrop { + position: fixed; + inset: 0; + z-index: var(--sum-z-modal, 1200); + background: color-mix(in srgb, var(--sum-ink) 45%, transparent); + display: flex; + align-items: center; + justify-content: center; + padding: var(--sum-space-md); +} + +.sum-attachment-preview { + background: var(--sum-surface); + border-radius: var(--sum-radius-md); + max-width: min(960px, 96vw); + max-height: 92vh; + display: flex; + flex-direction: column; + box-shadow: var(--sum-shadow-lg); +} + +.sum-attachment-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sum-space-sm); + padding: var(--sum-space-sm) var(--sum-space-md); + border-bottom: 1px solid var(--sum-border); +} + +.sum-attachment-preview-body { + padding: var(--sum-space-md); + overflow: auto; +} + +.sum-attachment-preview-img { + max-width: 100%; + height: auto; +} + +.sum-attachment-preview-pdf { + width: min(880px, 90vw); + height: min(70vh, 640px); + border: none; +} + .sum-form-reports-anchor, .sum-reports-anchor { position: relative; diff --git a/core/server/web/chatter_upload.go b/core/server/web/chatter_upload.go new file mode 100644 index 00000000..3c189f08 --- /dev/null +++ b/core/server/web/chatter_upload.go @@ -0,0 +1,80 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "path/filepath" + "strconv" + "strings" + + "sumeru/addons/mail" + "sumeru/core/orm" +) + +const chatterUploadRoute = "/web/chatter/upload" + +// ChatterUploadHandler POST multipart: model, res_id, file → JSON attachment metadata. +func ChatterUploadHandler(w http.ResponseWriter, r *http.Request) { + if !requireLoginMultipartPost(w, r, maxImportBodyBytes) { + return + } + if !mail.CompanyChatterEnabled(r.Context()) { + http.Error(w, "chatter disabled", http.StatusForbidden) + return + } + modelName := strings.TrimSpace(r.FormValue(recordModelField)) + resIDRaw := strings.TrimSpace(r.FormValue(chatterRecordIDField)) + resID, _ := strconv.ParseInt(resIDRaw, 10, 64) + if modelName == "" || resID <= 0 { + http.Error(w, "model and res_id required", http.StatusBadRequest) + return + } + if !requireModelAccess(w, r, modelName, "write") { + return + } + if !chatterTargetRecordExists(r, modelName, resID) { + http.Error(w, "record not found", http.StatusNotFound) + return + } + upload, header, err := r.FormFile(importFileField) + if err != nil { + http.Error(w, "file required", http.StatusBadRequest) + return + } + defer upload.Close() + content, err := io.ReadAll(upload) + if err != nil || len(content) == 0 { + http.Error(w, "empty file", http.StatusBadRequest) + return + } + name := strings.TrimSpace(header.Filename) + if name == "" { + name = "upload" + } + name = filepath.Base(name) + attID, err := orm.CreateBinaryAttachment(r.Context(), orm.CreateBinaryAttachmentInput{ + Name: name, + ResModel: modelName, + ResID: int(resID), + Data: content, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": attID, + "name": name, + "url": contentURL(attID, true), + }) +} + +func contentURL(attachmentID int, inline bool) string { + u := "/web/content/" + strconv.Itoa(attachmentID) + if inline { + u += "?download=0" + } + return u +} diff --git a/core/server/web/routes_table.go b/core/server/web/routes_table.go index 15cd7546..9bd12680 100644 --- a/core/server/web/routes_table.go +++ b/core/server/web/routes_table.go @@ -69,6 +69,7 @@ func registerWorkspaceRoutes() { registerSession(http.MethodGet, workspaceRoute, WebHandler) registerSession(http.MethodPost, companySwitchRoute, SwitchCompanyPost) registerSession(http.MethodPost, chatterPostRoute, ChatterPostHandler) + registerSession(http.MethodPost, chatterUploadRoute, ChatterUploadHandler) registerSession(http.MethodPost, importCSVRoute, ImportCSVHandler) } @@ -89,6 +90,11 @@ func registerRecordRoutes() { registerSession(http.MethodPost, bulkUploadRoute, BulkUploadHandler) registerSession(http.MethodPost, bulkConfirmRoute, BulkConfirmHandler) registerSession(http.MethodPost, bulkCancelRoute, BulkCancelHandler) + registerSession(http.MethodPost, bulkPreviewRoute, BulkPreviewHandler) + registerSession(http.MethodPost, bulkDryRunRoute, BulkDryRunHandler) + registerSession(http.MethodPost, bulkSaveTemplateRoute, BulkSaveImportTemplateHandler) + registerSession(http.MethodPost, exportAsyncRoute, ExportAsyncHandler) + registerSession(http.MethodGet, importWizardRoute, ImportWizardPageHandler) } func registerActionRoutes() { diff --git a/core/server/web/swc_chatter.go b/core/server/web/swc_chatter.go index 9cb46ab6..1aacee3a 100644 --- a/core/server/web/swc_chatter.go +++ b/core/server/web/swc_chatter.go @@ -22,11 +22,19 @@ type swcChatterMessage struct { Subtype string `json:"subtype"` } +type swcChatterAttachment struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Mimetype string `json:"mimetype,omitempty"` +} + type swcChatterPayload struct { - Model string `json:"model"` - RecordID int64 `json:"recordId"` - Messages []swcChatterMessage `json:"messages"` - Enabled bool `json:"enabled"` + Model string `json:"model"` + RecordID int64 `json:"recordId"` + Messages []swcChatterMessage `json:"messages"` + Attachments []swcChatterAttachment `json:"attachments"` + Enabled bool `json:"enabled"` } // SwcChatterHandler GET /web/swc/chatter?model=&id= @@ -63,6 +71,15 @@ func SwcChatterHandler(w http.ResponseWriter, r *http.Request) { Subtype: row.Subtype, }) } + attRows, _ := mail.ListAttachmentsForRecord(r.Context(), model, id, 80) + for _, a := range attRows { + out.Attachments = append(out.Attachments, swcChatterAttachment{ + ID: a.ID, + Name: a.Name, + URL: contentURL(int(a.ID), true), + Mimetype: a.Mimetype, + }) + } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(out) } diff --git a/core/server/web/testexports.go b/core/server/web/testexports.go index 1251c0c9..d7cc3c3d 100644 --- a/core/server/web/testexports.go +++ b/core/server/web/testexports.go @@ -392,6 +392,53 @@ func ExportTemplatePDFHandlerForTest(w http.ResponseWriter, r *http.Request) { ExportTemplatePDFHandler(w, r) } +func ContentHandlerForTest(w http.ResponseWriter, r *http.Request) { + ContentHandler(w, r) +} + +func ParseContentPathForTest(path string) (field string, id int, ok bool) { + spec, ok := parseContentPath(contentRoutePrefix + path) + if !ok { + return "", 0, false + } + if spec.attachmentID > 0 { + return "attachment", spec.attachmentID, true + } + return spec.resField, spec.resID, true +} + +func ContentInlineAllowedForTest(mime string) bool { return contentInlineAllowed(mime) } + +func SafeContentDispositionInlineForTest(name string) string { return safeContentDispositionInline(name) } + +func BulkPreviewHandlerForTest(w http.ResponseWriter, r *http.Request) { + BulkPreviewHandler(w, r) +} + +func BulkDryRunHandlerForTest(w http.ResponseWriter, r *http.Request) { + BulkDryRunHandler(w, r) +} + +func BulkSaveImportTemplateHandlerForTest(w http.ResponseWriter, r *http.Request) { + BulkSaveImportTemplateHandler(w, r) +} + +func SwcImportHandlerForTest(w http.ResponseWriter, r *http.Request) { + SwcImportHandler(w, r) +} + +func ImportWizardPageHandlerForTest(w http.ResponseWriter, r *http.Request) { + ImportWizardPageHandler(w, r) +} + +func ExportAsyncHandlerForTest(w http.ResponseWriter, r *http.Request) { + ExportAsyncHandler(w, r) +} + +func ChatterUploadHandlerForTest(w http.ResponseWriter, r *http.Request) { + ChatterUploadHandler(w, r) +} + // SetTestSessionUserIDForTest overrides SessionUserID for handler tests. func SetTestSessionUserIDForTest(userID int) { testSessionUserIDOverride = userID } diff --git a/core/server/web/web_constants.go b/core/server/web/web_constants.go index cbb2344e..e20ff2bc 100644 --- a/core/server/web/web_constants.go +++ b/core/server/web/web_constants.go @@ -273,6 +273,12 @@ const ( 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" + importWizardRoute = "/web/import" + swcImportRoute = "/web/swc/import" maxImportBodyBytes = 8 << 20 importModelField = "model" importFileField = "file" diff --git a/core/swc/src/services/http.ts b/core/swc/src/services/http.ts index a06754e1..5396f1ff 100644 --- a/core/swc/src/services/http.ts +++ b/core/swc/src/services/http.ts @@ -32,6 +32,25 @@ export class HttpService { }); } + async postMultipart(url: string, form: FormData, onProgress?: (pct: number) => void): Promise { + form.set("csrf_token", this.csrfToken); + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url); + xhr.withCredentials = true; + xhr.onload = () => resolve(new Response(xhr.responseText, { status: xhr.status })); + xhr.onerror = () => reject(new SwcError(`POST ${url} failed`, "http_post")); + if (onProgress) { + xhr.upload.onprogress = (ev) => { + if (ev.lengthComputable) { + onProgress(Math.round((ev.loaded / ev.total) * 100)); + } + }; + } + xhr.send(form); + }); + } + async postFormFields(url: string, data: Record): Promise { const body = new URLSearchParams(); body.set("csrf_token", this.csrfToken); diff --git a/core/swc/src/views/chatter/ChatterPanel.ts b/core/swc/src/views/chatter/ChatterPanel.ts index bafe6d5d..feef4452 100644 --- a/core/swc/src/views/chatter/ChatterPanel.ts +++ b/core/swc/src/views/chatter/ChatterPanel.ts @@ -2,6 +2,7 @@ import { SwcComponent } from "../../runtime/component.js"; import { html } from "../../template/html.js"; import { RECORD_UPDATED, SWC_API_BASE } from "../../constants/routes.js"; import { inputValueFromEvent } from "../../widgets/field-events.js"; +import { attachmentPreviewModal, type AttachmentPreviewTarget } from "../shared/AttachmentPreview.js"; interface ChatterMessage { body: string; @@ -38,6 +39,9 @@ export class ChatterPanel extends SwcComponent { private posting = false; private enabled = true; private tab: "messages" | "attachments" = "messages"; + private uploadPct = 0; + private uploading = false; + private preview: AttachmentPreviewTarget | null = null; private unsubRecordUpdated: (() => void) | null = null; override setup(): void { @@ -82,6 +86,32 @@ export class ChatterPanel extends SwcComponent { } } + private async uploadFile(file: File): Promise { + if (this.props.recordId <= 0) return; + this.uploading = true; + this.uploadPct = 0; + this.rerender(); + try { + const form = new FormData(); + form.set("model", this.props.model); + form.set("res_id", String(this.props.recordId)); + form.set("file", file); + const res = await this.env.services.http.postMultipart("/web/chatter/upload", form, (pct) => { + this.uploadPct = pct; + this.rerender(); + }); + if (!res.ok) { + return; + } + await this.load(); + this.tab = "attachments"; + } finally { + this.uploading = false; + this.uploadPct = 0; + this.rerender(); + } + } + private async post(): Promise { const body = this.draft.trim(); if (!body || this.props.recordId <= 0) return; @@ -122,13 +152,39 @@ export class ChatterPanel extends SwcComponent { ${this.tab === "attachments" - ? html`
    - ${this.attachments.length === 0 - ? html`
  • No attachments.
  • ` - : this.attachments.map( - (a) => html`
  • ${a.name}
  • `, - )} -
` + ? html`
+ +
    + ${this.attachments.length === 0 + ? html`
  • No attachments.
  • ` + : this.attachments.map( + (a) => html`
  • + +
  • `, + )} +
+
` : html`
- +
+ + +
-
    - ${this.messages.length === 0 - ? html`
  • No messages yet.
  • ` - : this.messages.map( - (m) => html`
  • -
    ${m.author} · ${m.createDate}
    -
    ${m.body}
    -
  • `, - )} -
`} ${attachmentPreviewModal(this.preview, () => { this.preview = null; this.rerender(); })} - + `; + if (this.props.embedded) { + return html`
${inner}
`; + } + return html``; } } diff --git a/core/swc/src/views/form/FormView.ts b/core/swc/src/views/form/FormView.ts index 4013da1f..a7827aa8 100644 --- a/core/swc/src/views/form/FormView.ts +++ b/core/swc/src/views/form/FormView.ts @@ -11,7 +11,6 @@ import { collectFormFields, renderFormSheet } from "./form-sheet.js"; import { initFormInteractions } from "./form-interactions.js"; import { validatePasswordMatchGroups } from "../../widgets/password-match.js"; import { FieldHost } from "../../widgets/field-host.js"; -import { ChatterPanel } from "../chatter/ChatterPanel.js"; import { isFieldVisible, modifierViewContext, resolveFieldModifiers } from "../../model/modifiers.js"; import { VIEW_FORM, VIEW_LIST } from "../../constants/routes.js"; import { runObjectAction } from "../shared/object-action.js"; @@ -39,29 +38,14 @@ export class FormView extends SwcComponent { private activeNotebookPages: Record = {}; private teardownInteractions: (() => void) | null = null; private fieldHost!: FieldHost; - private chatterPanel!: ChatterPanel; override setup(): void { this.fieldHost = new FieldHost(this.env); this.initRecordState(this.props.payload); - this.chatterPanel = new ChatterPanel( - { - model: this.props.payload.model, - recordId: this.props.payload.recordId, - csrfToken: this.props.payload.csrfToken, - }, - this.env, - ); - this.chatterPanel.callSetup(); } override onPropsChanged(props: FormViewProps): void { this.initRecordState(props.payload); - this.chatterPanel.updateProps({ - model: props.payload.model, - recordId: props.payload.recordId, - csrfToken: props.payload.csrfToken, - }); this.fieldHost.clear(); } @@ -79,7 +63,6 @@ export class FormView extends SwcComponent { this.teardownInteractions?.(); this.teardownInteractions = null; this.fieldHost.clear(); - this.chatterPanel.destroy(); } override patch(): void { @@ -366,7 +349,6 @@ export class FormView extends SwcComponent { }); const footerButtons = payload.arch.footer?.buttons ?? []; - const showChatter = payload.arch.hasChatter && payload.recordId > 0; return html`
@@ -404,7 +386,7 @@ export class FormView extends SwcComponent { }, })} ${this.error ? html`
${this.error}
` : ""} -
+
${sheet} ${footerButtons.length > 0 @@ -420,7 +402,6 @@ export class FormView extends SwcComponent {
` : ""}
- ${showChatter ? this.chatterPanel.render() : ""}
`; diff --git a/core/swc/src/views/workspace/WorkspaceRouter.ts b/core/swc/src/views/workspace/WorkspaceRouter.ts index 6bb3dcf9..48d15d58 100644 --- a/core/swc/src/views/workspace/WorkspaceRouter.ts +++ b/core/swc/src/views/workspace/WorkspaceRouter.ts @@ -11,6 +11,7 @@ import { ShellPageView } from "../../shell/ShellPageView.js"; import { syncWorkspaceViewTabs } from "../../shell/view-tab-sync.js"; import { syncWorkspaceBreadcrumbs } from "../../shell/breadcrumb-sync.js"; import { ACTION_CLOSED, RECORD_UPDATED, SWC_API_BASE } from "../../constants/routes.js"; +import { ACTIVITY_CONTEXT, activityContextFromWorkspace } from "../../shell/activity-context.js"; import { RouterService } from "../../services/router.js"; import { runWillStart } from "../../runtime/lifecycle.js"; @@ -69,9 +70,22 @@ export class WorkspaceRouter extends SwcComponent { syncWorkspaceViewTabs(this.payload.viewTabs); syncWorkspaceBreadcrumbs(this.payload.breadcrumbs); this.syncView(); + this.emitActivityContext(this.payload); void updateDebugDrawer(this.env.bootstrap, this.debugContext(this.payload)); } catch (err) { this.error = err instanceof SwcError ? err.message : String(err); + this.emitActivityContext({ + actionId: 0, + menuId: "", + viewType: "", + model: "", + recordId: 0, + formEdit: false, + csrfToken: "", + arch: { type: "", model: "", fields: [] }, + viewTabs: [], + breadcrumbs: [], + }); } finally { this.loading = false; this.rerender(); @@ -128,6 +142,10 @@ export class WorkspaceRouter extends SwcComponent { this.activeViewType = type; } + private emitActivityContext(payload: SwcWorkspacePayload): void { + this.env.services.bus.emit(ACTIVITY_CONTEXT, activityContextFromWorkspace(payload)); + } + private renderView(): HTMLElement { if (!this.payload || !this.activeView) return document.createElement("div"); return this.activeView.renderOrPatch(); @@ -141,6 +159,7 @@ export class WorkspaceRouter extends SwcComponent { syncWorkspaceViewTabs(payload.viewTabs); syncWorkspaceBreadcrumbs(payload.breadcrumbs); this.syncView(); + this.emitActivityContext(payload); this.patch(); void updateDebugDrawer(this.env.bootstrap, this.debugContext(payload)); }) diff --git a/core/swc/tests/views/chatter-panel.test.ts b/core/swc/tests/views/chatter-panel.test.ts index 164f7079..f6be1d5c 100644 --- a/core/swc/tests/views/chatter-panel.test.ts +++ b/core/swc/tests/views/chatter-panel.test.ts @@ -7,7 +7,7 @@ describe("ChatterPanel", () => { const panel = new ChatterPanel({ model: "demo.model", recordId: 0, csrfToken: "tok" }, collectionEnv()); panel.callSetup(); const el = panel.render(); - expect(el.classList.contains("sum-chatter--empty")).toBe(true); + expect(el.textContent).toContain("Save the record"); panel.destroy(); }); @@ -21,13 +21,15 @@ describe("ChatterPanel", () => { }), postForm: vi.fn().mockResolvedValue(undefined), postJSON: vi.fn(), + postMultipart: vi.fn(), }, }); - const panel = new ChatterPanel({ model: "demo.model", recordId: 5, csrfToken: "tok" }, env); + const panel = new ChatterPanel({ model: "demo.model", recordId: 5, csrfToken: "tok", embedded: true }, env); panel.callSetup(); await vi.waitFor(() => { expect(panel.render().querySelector(".sum-chatter-message")?.textContent).toContain("Hello"); }); + expect(panel.render().querySelector(".sum-chatter-attachment-link")?.textContent).toContain("doc.pdf"); const textarea = panel.render().querySelector(".sum-chatter-input") as HTMLTextAreaElement; textarea.value = "Reply"; textarea.dispatchEvent(new Event("input", { bubbles: true })); @@ -35,25 +37,4 @@ describe("ChatterPanel", () => { await vi.waitFor(() => expect(env.services.http.postForm).toHaveBeenCalled()); panel.destroy(); }); - - it("switches to attachments tab", async () => { - const env = collectionEnv({ - http: { - getJSON: vi.fn().mockResolvedValue({ - messages: [], - attachments: [{ id: 2, name: "file.txt", url: "/file.txt" }], - enabled: true, - }), - postForm: vi.fn(), - postJSON: vi.fn(), - }, - }); - const panel = new ChatterPanel({ model: "demo.model", recordId: 3, csrfToken: "tok" }, env); - panel.callSetup(); - await vi.waitFor(() => expect(panel.render().querySelector(".sum-chatter")).toBeTruthy()); - const tabs = panel.render().querySelectorAll(".sum-chatter-tab"); - (tabs[1] as HTMLButtonElement).click(); - expect(panel.render().querySelector(".sum-chatter-attachments")?.textContent).toContain("file.txt"); - panel.destroy(); - }); }); diff --git a/test/addons/automation/init_test.go b/test/addons/automation/init_test.go index 7d4ebe33..f1c97f2d 100644 --- a/test/addons/automation/init_test.go +++ b/test/addons/automation/init_test.go @@ -12,8 +12,8 @@ import ( func TestShouldMaterializeServerActionRequiresAutomationModule(t *testing.T) { - _ = modelreg.ActivateAll([]string{"base", "mail", "automation"}) - installed := map[string]struct{}{"base": {}, "mail": {}} + _ = modelreg.ActivateAll([]string{"base", "im", "automation"}) + installed := map[string]struct{}{"base": {}, "im": {}} if orm.ShouldMaterializeModel("sys.server.action", installed) { t.Fatal("sys.server.action should not materialize without automation module") } diff --git a/test/addons/mail/list_attachments_test.go b/test/addons/mail/list_attachments_test.go deleted file mode 100644 index 939e0893..00000000 --- a/test/addons/mail/list_attachments_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package mail_test - -import ( - "context" - "testing" - - "sumeru/addons/mail" -) - -func TestListAttachmentsForRecordInvalid(t *testing.T) { - _, err := mail.ListAttachmentsForRecord(context.Background(), "", 0, 10) - if err == nil { - t.Fatal("expected error") - } - _, err = mail.ListAttachmentsForRecord(context.Background(), "not.a.model", 1, 10) - if err == nil { - t.Fatal("expected unknown model error") - } -} diff --git a/test/core/mail/core_mail_test.go b/test/core/mail/core_mail_test.go deleted file mode 100644 index 474e9a5c..00000000 --- a/test/core/mail/core_mail_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package mail_test - -import ( - "context" - "testing" - - coremail "sumeru/core/mail" -) - -func TestConfigureAndConfigured(t *testing.T) { - coremail.Configure(coremail.SMTPConfig{}) - if coremail.Configured() { - t.Fatal("empty config not configured") - } - coremail.Configure(coremail.SMTPConfig{Host: "smtp.example.com", From: "noreply@example.com"}) - if !coremail.Configured() { - t.Fatal("expected configured") - } - coremail.Configure(coremail.SMTPConfig{Host: "smtp.example.com", Port: 0, From: "noreply@example.com"}) - if err := coremail.Send(context.Background(), "", "subj", "body"); err == nil { - t.Fatal("empty recipient") - } - if err := coremail.Send(context.Background(), "user@example.com", "subj", "body"); err == nil { - t.Fatal("Send without server should fail") - } -} - -func TestSendPasswordResetEmailNotConfigured(t *testing.T) { - coremail.Configure(coremail.SMTPConfig{}) - if err := coremail.SendPasswordResetEmail(context.Background(), "u@x.com", "admin", "http://localhost/"); err == nil { - t.Fatal("expected smtp not configured error") - } -} - -func TestEnqueueDoesNotPanic(t *testing.T) { - coremail.Enqueue(context.Background(), "nobody@example.com", "subj", "body") -} diff --git a/test/core/mail/mail_access_csv_test.go b/test/core/mail/mail_access_csv_test.go deleted file mode 100644 index b3691027..00000000 --- a/test/core/mail/mail_access_csv_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package mail_test - -import ( - "encoding/csv" - "os" - "path/filepath" - "strconv" - "strings" - "testing" -) - -func TestMailAccessCSV_noPermissiveEmptyGroupID(t *testing.T) { - t.Helper() - root := findRepoRoot(t) - path := filepath.Join(root, "addons", "mail", "security", "sys.access.csv") - f, err := os.Open(path) - if err != nil { - t.Fatalf("open csv: %v", err) - } - defer f.Close() - - rows, err := csv.NewReader(f).ReadAll() - if err != nil { - t.Fatalf("read csv: %v", err) - } - if len(rows) < 2 { - t.Fatal("expected header and data rows") - } - header := rows[0] - groupCol := csvColIndex(header, "group_id") - if groupCol < 0 { - t.Fatal("group_id column missing") - } - permCols := []string{"perm_write", "perm_create", "perm_unlink"} - permIdx := make([]int, len(permCols)) - for i, name := range permCols { - permIdx[i] = csvColIndex(header, name) - if permIdx[i] < 0 { - t.Fatalf("column %q missing", name) - } - } - - for _, row := range rows[1:] { - if len(row) <= groupCol { - continue - } - groupID := strings.TrimSpace(row[groupCol]) - if groupID != "" { - continue - } - for i, col := range permIdx { - if col >= len(row) { - continue - } - on, err := strconv.Atoi(strings.TrimSpace(row[col])) - if err != nil { - t.Fatalf("parse %s: %v", permCols[i], err) - } - if on == 1 { - t.Fatalf("row %q has empty group_id with %s=1", row[0], permCols[i]) - } - } - } -} - -func csvColIndex(header []string, name string) int { - for i, h := range header { - if strings.TrimSpace(h) == name { - return i - } - } - return -1 -} - -func findRepoRoot(t *testing.T) string { - t.Helper() - dir, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - if _, err := os.Stat(filepath.Join(dir, "addons", "mail")); err == nil { - return dir - } - } - parent := filepath.Dir(dir) - if parent == dir { - t.Fatal("could not find sumeru module root") - } - dir = parent - } -} diff --git a/test/core/mail/mail_acl_integration_test.go b/test/core/mail/mail_acl_integration_test.go deleted file mode 100644 index 113cd086..00000000 --- a/test/core/mail/mail_acl_integration_test.go +++ /dev/null @@ -1,289 +0,0 @@ -//go:build integration - -package mail_test - -import ( - "context" - "database/sql" - "fmt" - "os" - "testing" - "time" - - _ "github.com/lib/pq" - - "sumeru/addons/mail" - "sumeru/core/orm" -) - -const mailACLDomainForce = `["|", ["company_id", "=", false], ["company_id", "in", "$company_ids"]]` - -func mailIntegrationCtx() context.Context { - ctx := orm.ContextWithBypass(context.Background(), true) - return orm.ContextWithUID(ctx, 1) -} - -func initMailIntegrationDB(t *testing.T) context.Context { - t.Helper() - dsn := os.Getenv("SUMERU_TEST_DSN") - if dsn == "" { - t.Skip("SUMERU_TEST_DSN not set") - } - preflight, err := sql.Open("postgres", dsn) - if err != nil { - t.Fatalf("open db: %v", err) - } - if err := preflight.Ping(); err != nil { - _ = preflight.Close() - t.Fatalf("db ping: %v", err) - } - _ = preflight.Close() - - orm.InitDBWithPool(dsn, orm.DBPoolSettings{MaxOpenConns: 5, MaxIdleConns: 2}) - if !orm.IsInitialized() { - t.Skip("database schema not bootstrapped (run sumeru -i base)") - } - if _, ok := orm.Registry["mail.message"]; !ok { - t.Skip("mail.message not registered (install mail module)") - } - ctx := mailIntegrationCtx() - ensureMailSecurity(t, ctx) - return ctx -} - -func ensureMailSecurity(t *testing.T, ctx context.Context) { - t.Helper() - userGID, _, err := orm.ResolveXmlId(ctx, "base.group_user") - if err != nil || userGID <= 0 { - t.Fatalf("resolve base.group_user: %v", err) - } - systemGID, _, err := orm.ResolveXmlId(ctx, "base.group_system") - if err != nil || systemGID <= 0 { - t.Fatalf("resolve base.group_system: %v", err) - } - portalGID, _, err := orm.ResolveXmlId(ctx, "base.group_portal") - if err != nil || portalGID <= 0 { - t.Fatalf("resolve base.group_portal: %v", err) - } - _ = portalGID - - accessModel := orm.RegistryModel("sys.access") - upsertAccess := func(name, model string, groupID int, read, write, create, unlink bool) { - t.Helper() - _, err := orm.Upsert(ctx, accessModel, map[string]interface{}{ - "name": name, - "model": model, - "group_id": groupID, - "perm_read": read, - "perm_write": write, - "perm_create": create, - "perm_unlink": unlink, - }, "name") - if err != nil { - t.Fatalf("upsert access %s: %v", name, err) - } - } - upsertAccess("access_mail_message_user", "mail.message", userGID, true, true, true, false) - upsertAccess("access_mail_activity_user", "mail.activity", userGID, true, true, true, true) - upsertAccess("access_mail_activity_type_user", "mail.activity.type", systemGID, true, true, true, true) - upsertAccess("access_mail_activity_plan_user", "mail.activity.plan", systemGID, true, true, true, true) - upsertAccess("access_mail_activity_plan_template_user", "mail.activity.plan.template", systemGID, true, true, true, true) - upsertAccess("access_mail_template_user", "mail.template", systemGID, true, true, true, true) - - ruleModel := orm.RegistryModel("sys.rule") - upsertRule := func(name, model string, unlink bool) int { - t.Helper() - id, err := orm.Upsert(ctx, ruleModel, map[string]interface{}{ - "name": name, - "model": model, - "domain_force": mailACLDomainForce, - "active": true, - "perm_read": true, - "perm_write": true, - "perm_create": true, - "perm_unlink": unlink, - }, "name") - if err != nil { - t.Fatalf("upsert rule %s: %v", name, err) - } - linkRuleGroup(t, ctx, id, userGID) - return id - } - upsertRule("Mail Message: multi-company", "mail.message", false) - upsertRule("Mail Activity: multi-company", "mail.activity", true) - orm.InvalidateRuleCache() -} - -func linkRuleGroup(t *testing.T, ctx context.Context, ruleID, groupID int) { - t.Helper() - relTbl := orm.MustQuotedTableName("sys.rule.group.rel") - _, err := orm.DB.ExecContext(ctx, - `DELETE FROM `+relTbl+` WHERE rule_id = $1`, ruleID) - if err != nil { - t.Fatalf("clear rule groups: %v", err) - } - _, err = orm.DB.ExecContext(ctx, - `INSERT INTO `+relTbl+` (rule_id, group_id) VALUES ($1, $2) ON CONFLICT (rule_id, group_id) DO NOTHING`, - ruleID, groupID) - if err != nil { - t.Fatalf("link rule group: %v", err) - } -} - -func createMailTestCompany(t *testing.T, ctx context.Context, label string) int { - t.Helper() - model, ok := orm.Registry["core.company"] - if !ok { - t.Fatal("core.company not registered") - } - name := fmt.Sprintf("Mail ACL %s %d", label, time.Now().UnixNano()) - id, err := orm.Create(ctx, model, map[string]interface{}{"name": name}) - if err != nil { - t.Fatalf("create company: %v", err) - } - return id -} - -func createMailTestUser(t *testing.T, ctx context.Context, label string, groupIDs, companyIDs []int) int { - t.Helper() - userModel, ok := orm.Registry["core.user"] - if !ok { - t.Fatal("core.user not registered") - } - login := fmt.Sprintf("mail_acl_%s_%d", label, time.Now().UnixNano()) - userID, err := orm.Create(ctx, userModel, map[string]interface{}{ - "login": login, - "name": "Mail ACL " + label, - }) - if err != nil { - t.Fatalf("create user: %v", err) - } - if err := orm.SetUserGroupLinks(ctx, userID, groupIDs); err != nil { - t.Fatalf("set groups: %v", err) - } - if err := orm.SetUserCompanyLinks(ctx, userID, companyIDs); err != nil { - t.Fatalf("set companies: %v", err) - } - return userID -} - -func createMailMessage(t *testing.T, ctx context.Context, resModel string, resID, companyID int, body string) int { - t.Helper() - model, ok := orm.Registry["mail.message"] - if !ok { - t.Fatal("mail.message not registered") - } - vals := map[string]interface{}{ - "model": resModel, - "core_id": resID, - "body": body, - "subtype": mail.SubtypeComment, - "author": "Test", - "create_date": time.Now().UTC(), - "company_id": companyID, - } - id, err := orm.Create(ctx, model, vals) - if err != nil { - t.Fatalf("create mail.message: %v", err) - } - return id -} - -func userCtx(uid int) context.Context { - return orm.ContextWithUID(context.Background(), uid) -} - -func TestMailMessageCrossCompanySearchDenied(t *testing.T) { - ctx := initMailIntegrationDB(t) - userGID, _, err := orm.ResolveXmlId(ctx, "base.group_user") - if err != nil || userGID <= 0 { - t.Fatalf("resolve group_user: %v", err) - } - - companyA := createMailTestCompany(t, ctx, "A") - companyB := createMailTestCompany(t, ctx, "B") - userA := createMailTestUser(t, ctx, "A", []int{userGID}, []int{companyA}) - _ = createMailTestUser(t, ctx, "B", []int{userGID}, []int{companyB}) - - msgA := createMailMessage(t, ctx, "core.user", 1, companyA, "secret A") - msgB := createMailMessage(t, ctx, "core.user", 1, companyB, "secret B") - - ctxA := userCtx(userA) - rows, err := orm.Search(ctxA, "mail.message", nil) - if err != nil { - t.Fatalf("search as user A: %v", err) - } - for _, row := range rows { - id, _ := row["id"].(int) - if id == msgB { - t.Fatalf("user A must not see company B message id=%d", msgB) - } - } - foundA := false - for _, row := range rows { - id, _ := row["id"].(int) - if id == msgA { - foundA = true - } - } - if !foundA { - t.Fatalf("user A should see own company message id=%d", msgA) - } - - count, err := orm.SearchCount(ctxA, "mail.message", nil) - if err != nil { - t.Fatalf("search_count: %v", err) - } - for _, row := range rows { - id, _ := row["id"].(int) - if id == msgB { - t.Fatal("search_count path must not include company B message") - } - } - if count < 1 { - t.Fatal("expected at least one visible message for user A") - } -} - -func TestMailMessagePortalUserDenied(t *testing.T) { - ctx := initMailIntegrationDB(t) - portalGID, _, err := orm.ResolveXmlId(ctx, "base.group_portal") - if err != nil || portalGID <= 0 { - t.Fatalf("resolve portal group: %v", err) - } - portalUID := createMailTestUser(t, ctx, "portal", []int{portalGID}, nil) - ctxPortal := userCtx(portalUID) - err = orm.CheckModelAccess(ctxPortal, portalUID, "mail.message", "read") - if err == nil { - t.Fatal("portal user should not have mail.message read access") - } - if !orm.IsAccessDenied(err) { - t.Fatalf("expected access denied, got %v", err) - } -} - -func TestListCommentsRespectsCompanyRule(t *testing.T) { - ctx := initMailIntegrationDB(t) - userGID, _, err := orm.ResolveXmlId(ctx, "base.group_user") - if err != nil || userGID <= 0 { - t.Fatalf("resolve group_user: %v", err) - } - - companyA := createMailTestCompany(t, ctx, "listA") - companyB := createMailTestCompany(t, ctx, "listB") - userA := createMailTestUser(t, ctx, "listUserA", []int{userGID}, []int{companyA}) - - recordID := createMailTestUser(t, ctx, "target", []int{userGID}, []int{companyA, companyB}) - _ = createMailMessage(t, ctx, "core.user", recordID, companyB, "cross-company comment") - - ctxA := userCtx(userA) - rows, err := mail.ListCommentsForRecord(ctxA, "core.user", int64(recordID), 50) - if err != nil { - t.Fatalf("ListCommentsForRecord: %v", err) - } - for _, row := range rows { - if row.Body == "cross-company comment" { - t.Fatal("user A must not see company B comment via chatter list") - } - } -} diff --git a/test/core/mail/mail_test.go b/test/core/mail/mail_test.go deleted file mode 100644 index d012d716..00000000 --- a/test/core/mail/mail_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package mail_test - -import ( - "context" - "testing" - - "sumeru/addons/mail" -) - -func TestPostMessage_requiresFields(t *testing.T) { - err := mail.PostMessage(context.Background(), "", 1, "hi", mail.SubtypeComment, "A") - if err == nil { - t.Fatal("expected error for empty model") - } - err = mail.PostMessage(context.Background(), "core.user", 1, "", mail.SubtypeComment, "A") - if err == nil { - t.Fatal("expected error for empty body") - } - err = mail.PostMessage(context.Background(), "core.user", 1, "hi", "", "A") - if err == nil { - t.Fatal("expected error for empty subtype") - } -} - -func TestPostMessage_unknownModelWithoutDB(t *testing.T) { - // Without DB init, still validates model registry membership. - err := mail.PostMessage(context.Background(), "no.such.model", 1, "body", mail.SubtypeComment, "A") - if err == nil { - t.Fatal("expected error") - } -} - -func TestCompanyFlags_defaultTrueWithoutDB(t *testing.T) { - ctx := context.Background() - if !mail.CompanyChatterEnabled(ctx) { - t.Fatal("default chatter enabled") - } - if !mail.CompanyActivityPanelEnabled(ctx) { - t.Fatal("default activity panel enabled") - } -} - -func TestListComments_nilDB(t *testing.T) { - rows, err := mail.ListCommentsForRecord(context.Background(), "core.user", 1, 10) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("expected empty, got %v", rows) - } -} diff --git a/test/core/mail/main_test.go b/test/core/mail/main_test.go deleted file mode 100644 index 08054e8e..00000000 --- a/test/core/mail/main_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package mail_test - -import ( - "os" - "testing" - - _ "sumeru/addons/base/models" - _ "sumeru/addons/mail/models" - "sumeru/core/modelreg" - _ "sumeru/core/ormmodels" -) - -func TestMain(m *testing.M) { - if err := modelreg.ActivateAll([]string{"base", "mail"}); err != nil { - panic(err) - } - os.Exit(m.Run()) -} diff --git a/test/core/server/web/record_chatter_post_test.go b/test/core/server/web/record_chatter_post_test.go deleted file mode 100644 index 235f0b28..00000000 --- a/test/core/server/web/record_chatter_post_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package web_test - -import ( - "net/http/httptest" - "strings" - "testing" - - "sumeru/core/server/web" -) - -func TestParseChatterPostForm(t *testing.T) { - body := "model=sale.order&res_id=42&body=Hello&next=%2Fweb%3Fid%3D42" - req := httptest.NewRequest("POST", web.TestChatterPostRoute, strings.NewReader(body)) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - if err := req.ParseForm(); err != nil { - t.Fatal(err) - } - - form := web.ParseChatterPostForm(req) - if form.ModelName != "sale.order" || form.RecordIDRaw != "42" || form.Body != "Hello" || form.Next != "/web?id=42" { - t.Fatalf("unexpected form: %+v", form) - } -} - -func TestChatterBodyTooLong(t *testing.T) { - shortBody := strings.Repeat("a", web.TestMaxChatterBodyRunes) - if web.ChatterBodyTooLong(shortBody) { - t.Fatal("body at limit should be allowed") - } - if !web.ChatterBodyTooLong(shortBody + "b") { - t.Fatal("body over limit should be rejected") - } -} - -func TestParseChatterRecordID(t *testing.T) { - recordID, err := web.ParseChatterRecordID("15") - if err != nil || recordID != 15 { - t.Fatalf("got id=%d err=%v", recordID, err) - } - - _, err = web.ParseChatterRecordID("0") - if err == nil { - t.Fatal("expected error for invalid id") - } -} From 15fb77e6d660898a076280e4fe166d63457c7b7c Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sun, 4 Oct 2026 01:37:42 +0530 Subject: [PATCH 08/15] Wire activity dock to audit log and im direct messages. Replace record chatter UI with Log tab and P2P internal messaging aligned with the im module. --- .../automation/data/audit_retention_cron.xml | 11 + core/engine/assets/css/sumeru-messages.css | 81 +++++ core/engine/assets/css/sumeru-shell.css | 4 + core/engine/render/swc_bootstrap.go | 4 +- core/engine/templates/shell/base.html | 42 +-- core/orm/audit_format.go | 199 ++++++++++++ core/server/web/routes_table.go | 2 - core/server/web/swc_activity_log.go | 100 +++++++ core/server/web/swc_direct.go | 182 +++++++++++ core/server/web/swc_notifications.go | 106 +------ core/server/web/swc_payload.go | 13 + core/server/web/swc_workspace.go | 3 +- core/server/web/testexports.go | 39 ++- core/server/web/web_constants.go | 12 +- core/swc/src/main.ts | 2 +- core/swc/src/shell/activity-context.ts | 40 +++ core/swc/src/shell/activity-log.ts | 85 ++++++ core/swc/src/shell/activity-messages.ts | 17 ++ core/swc/src/shell/activity-panel.ts | 48 ++- core/swc/src/shell/internal-messages-panel.ts | 283 ++++++++++++++++++ core/swc/src/shell/shell-chrome.ts | 11 +- core/swc/tests/views/activity-panel.test.ts | 18 +- test/addons/audit/audit_cleanup_test.go | 37 +++ test/addons/audit/audit_export_test.go | 69 +++++ test/addons/audit/audit_retention_test.go | 76 +++++ test/core/im/im_test.go | 68 +++++ test/core/im/main_test.go | 11 + test/core/orm/audit_format_test.go | 44 +++ test/core/server/web/swc_activity_log_test.go | 29 ++ test/core/server/web/swc_direct_test.go | 60 ++++ 30 files changed, 1506 insertions(+), 190 deletions(-) create mode 100644 addons/automation/data/audit_retention_cron.xml create mode 100644 core/orm/audit_format.go create mode 100644 core/server/web/swc_activity_log.go create mode 100644 core/server/web/swc_direct.go create mode 100644 core/swc/src/shell/activity-context.ts create mode 100644 core/swc/src/shell/activity-log.ts create mode 100644 core/swc/src/shell/activity-messages.ts create mode 100644 core/swc/src/shell/internal-messages-panel.ts create mode 100644 test/addons/audit/audit_cleanup_test.go create mode 100644 test/addons/audit/audit_export_test.go create mode 100644 test/addons/audit/audit_retention_test.go create mode 100644 test/core/im/im_test.go create mode 100644 test/core/im/main_test.go create mode 100644 test/core/orm/audit_format_test.go create mode 100644 test/core/server/web/swc_activity_log_test.go create mode 100644 test/core/server/web/swc_direct_test.go diff --git a/addons/automation/data/audit_retention_cron.xml b/addons/automation/data/audit_retention_cron.xml new file mode 100644 index 00000000..683b48d0 --- /dev/null +++ b/addons/automation/data/audit_retention_cron.xml @@ -0,0 +1,11 @@ + + + + + Audit log retention and export + audit.retention + 1440 + True + + + diff --git a/core/engine/assets/css/sumeru-messages.css b/core/engine/assets/css/sumeru-messages.css index 6b4662b2..b39e601a 100644 --- a/core/engine/assets/css/sumeru-messages.css +++ b/core/engine/assets/css/sumeru-messages.css @@ -195,3 +195,84 @@ outline: none; box-shadow: var(--sum-focus-ring); } + +.sum-msg-shell--dock { + height: 100%; + min-height: 12rem; +} + +.sum-msg-layout { + display: flex; + flex-direction: column; + gap: 0.5rem; + height: 100%; + min-height: 0; +} + +.sum-msg-sidebar { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.sum-msg-search { + width: 100%; + box-sizing: border-box; + padding: 0.4rem 0.55rem; + font-size: var(--sum-font-size-sm); + border: 1px solid var(--sum-line); + border-radius: var(--sum-radius-sm); +} + +.sum-msg-peer-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 8rem; + overflow: auto; + border: 1px solid var(--sum-line); + border-radius: var(--sum-radius-sm); +} + +.sum-msg-peer-btn { + width: 100%; + text-align: left; + padding: 0.45rem 0.55rem; + border: none; + border-bottom: 1px solid var(--sum-line); + background: var(--sum-surface); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.sum-msg-peer-btn.is-active, +.sum-msg-peer-btn:hover { + background: var(--sum-row-hover-bg); +} + +.sum-msg-peer-btn:last-child { + border-bottom: none; +} + +.sum-msg-main { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.sum-msg-thread-head { + font-size: var(--sum-font-size-sm); + font-weight: var(--sum-font-weight-semibold); + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--sum-line); + margin-bottom: 0.35rem; +} + +.sum-msg-card--out { + margin-left: 1.25rem; + background: var(--sum-header-tint-08); +} diff --git a/core/engine/assets/css/sumeru-shell.css b/core/engine/assets/css/sumeru-shell.css index 9f576991..99184b0d 100644 --- a/core/engine/assets/css/sumeru-shell.css +++ b/core/engine/assets/css/sumeru-shell.css @@ -918,6 +918,10 @@ padding: 0.65rem 0.75rem; } +.sum-activity-pane[hidden] { + display: none !important; +} + .sum-activity-pane--messages { display: flex; flex-direction: column; diff --git a/core/engine/render/swc_bootstrap.go b/core/engine/render/swc_bootstrap.go index 3c2ecb55..ba91425e 100644 --- a/core/engine/render/swc_bootstrap.go +++ b/core/engine/render/swc_bootstrap.go @@ -6,7 +6,7 @@ import ( "html/template" "strings" - "sumeru/addons/mail" + "sumeru/addons/im" "sumeru/core/orm" ) @@ -197,7 +197,7 @@ func shellActivityEnabled(ctx context.Context, page PageData) bool { if page.ActivityEnabled { return true } - return mail.CompanyChatterEnabled(ctx) && mail.CompanyActivityPanelEnabled(ctx) + return im.IMEnabled(ctx) } func parseSWCPinnedApps(raw template.JS) []string { diff --git a/core/engine/templates/shell/base.html b/core/engine/templates/shell/base.html index 39499da4..a51ae6b7 100644 --- a/core/engine/templates/shell/base.html +++ b/core/engine/templates/shell/base.html @@ -272,7 +272,7 @@

{{.Name}}

+ data-sum-activity-tab="log" hidden>Log
- {{if .ActivityPanelChatter}} - {{.ActivityChatterHTML}} - {{else}} -
- -

Messages

-

Open a record to view and post comments here. List - views show the activity log on the Log tab.

-
- {{end}} -
+ role="tabpanel"> + role="tabpanel" hidden> {{- end -}} diff --git a/core/orm/audit_format.go b/core/orm/audit_format.go new file mode 100644 index 00000000..92b3cc4d --- /dev/null +++ b/core/orm/audit_format.go @@ -0,0 +1,199 @@ +package orm + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" +) + +const auditDisplayFieldCap = 8 + +var auditSkipDiffFields = map[string]bool{ + "id": true, "write_date": true, "write_uid": true, +} + +// FormatAuditLogBody builds a human-readable change summary for the activity Log tab (sys.audit only). +func FormatAuditLogBody(ctx context.Context, model, action, beforeJSON, afterJSON, detail string) string { + action = strings.TrimSpace(action) + detail = strings.TrimSpace(detail) + switch action { + case "create", "insert": + if body := formatAuditFieldLines(ctx, model, nil, parseAuditJSON(afterJSON), true); body != "" { + return body + } + return "Created record" + case "unlink", "delete": + return "Deleted record" + case "write", "update", "upsert": + before := parseAuditJSON(beforeJSON) + after := parseAuditJSON(afterJSON) + if body := formatAuditDiff(ctx, model, before, after); body != "" { + return body + } + } + if detail != "" { + return detail + } + if action != "" { + return action + } + return "Change recorded" +} + +func parseAuditJSON(raw string) map[string]interface{} { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil + } + return m +} + +func formatAuditDiff(ctx context.Context, model string, before, after map[string]interface{}) string { + if before == nil && after == nil { + return "" + } + if before == nil { + return formatAuditFieldLines(ctx, model, nil, after, false) + } + if after == nil { + after = before + } + redact := sensitiveAuditFields(model) + var keys []string + seen := map[string]bool{} + for k := range before { + if auditSkipDiffFields[k] || redact[k] { + continue + } + seen[k] = true + if !auditValuesEqual(before[k], after[k]) { + keys = append(keys, k) + } + } + for k := range after { + if auditSkipDiffFields[k] || redact[k] || seen[k] { + continue + } + if !auditValuesEqual(before[k], after[k]) { + keys = append(keys, k) + } + } + sort.Strings(keys) + if len(keys) == 0 { + return "" + } + extra := 0 + if len(keys) > auditDisplayFieldCap { + extra = len(keys) - auditDisplayFieldCap + keys = keys[:auditDisplayFieldCap] + } + var lines []string + for _, k := range keys { + label := auditFieldLabel(ctx, model, k) + oldV := formatAuditValue(before[k]) + newV := formatAuditValue(after[k]) + if oldV == "" && newV != "" { + lines = append(lines, fmt.Sprintf("%s: %s", label, newV)) + } else if newV == "" && oldV != "" { + lines = append(lines, fmt.Sprintf("%s: cleared (was %s)", label, oldV)) + } else { + lines = append(lines, fmt.Sprintf("%s: %s → %s", label, oldV, newV)) + } + } + if extra > 0 { + lines = append(lines, fmt.Sprintf("and %d more field(s)", extra)) + } + return strings.Join(lines, "\n") +} + +func formatAuditFieldLines(ctx context.Context, model string, _ map[string]interface{}, after map[string]interface{}, createMode bool) string { + if len(after) == 0 { + return "" + } + redact := sensitiveAuditFields(model) + var keys []string + for k, v := range after { + if auditSkipDiffFields[k] || redact[k] { + continue + } + if formatAuditValue(v) == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) == 0 { + return "" + } + extra := 0 + if len(keys) > auditDisplayFieldCap { + extra = len(keys) - auditDisplayFieldCap + keys = keys[:auditDisplayFieldCap] + } + var lines []string + for _, k := range keys { + label := auditFieldLabel(ctx, model, k) + val := formatAuditValue(after[k]) + if createMode { + lines = append(lines, fmt.Sprintf("%s: %s", label, val)) + } else { + lines = append(lines, fmt.Sprintf("%s: %s", label, val)) + } + } + if extra > 0 { + lines = append(lines, fmt.Sprintf("and %d more field(s)", extra)) + } + return strings.Join(lines, "\n") +} + +func sensitiveAuditFields(model string) map[string]bool { + return readRedactFields(model) +} + +func auditFieldLabel(ctx context.Context, model, fieldName string) string { + defaultLabel := fieldName + if fd := FieldDef(model, fieldName); fd != nil && strings.TrimSpace(fd.String) != "" { + defaultLabel = strings.TrimSpace(fd.String) + } + return TranslateFieldLabel(ctx, model, fieldName, defaultLabel) +} + +func formatAuditValue(v interface{}) string { + if v == nil { + return "" + } + switch x := v.(type) { + case bool: + if x { + return "Yes" + } + return "No" + case float64: + if x == float64(int64(x)) { + return fmt.Sprintf("%d", int64(x)) + } + return fmt.Sprintf("%v", x) + case string: + s := strings.TrimSpace(x) + if len(s) > 120 { + return s[:117] + "…" + } + return s + default: + s := strings.TrimSpace(fmt.Sprintf("%v", v)) + if len(s) > 120 { + return s[:117] + "…" + } + return s + } +} + +func auditValuesEqual(a, b interface{}) bool { + return formatAuditValue(a) == formatAuditValue(b) +} diff --git a/core/server/web/routes_table.go b/core/server/web/routes_table.go index 9bd12680..382231ea 100644 --- a/core/server/web/routes_table.go +++ b/core/server/web/routes_table.go @@ -68,8 +68,6 @@ func registerWorkspaceRoutes() { registerSession(http.MethodPost, pinnedAppsRoute, PinnedAppsSaveHandler) registerSession(http.MethodGet, workspaceRoute, WebHandler) registerSession(http.MethodPost, companySwitchRoute, SwitchCompanyPost) - registerSession(http.MethodPost, chatterPostRoute, ChatterPostHandler) - registerSession(http.MethodPost, chatterUploadRoute, ChatterUploadHandler) registerSession(http.MethodPost, importCSVRoute, ImportCSVHandler) } diff --git a/core/server/web/swc_activity_log.go b/core/server/web/swc_activity_log.go new file mode 100644 index 00000000..a41a379b --- /dev/null +++ b/core/server/web/swc_activity_log.go @@ -0,0 +1,100 @@ +package web + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "sumeru/core/orm" +) + +const swcActivityLogRoute = "/web/swc/activity-log" + +func registerSwcActivityLogRoute() { + registerSession(http.MethodGet, swcActivityLogRoute, SwcActivityLogHandler) +} + +type swcActivityLogItem struct { + Meta string `json:"meta"` + Body string `json:"body"` + Action string `json:"action,omitempty"` + CreateDate string `json:"createDate,omitempty"` +} + +type swcActivityLogPayload struct { + Items []swcActivityLogItem `json:"items"` + HasLog bool `json:"hasLog"` +} + +// SwcActivityLogHandler GET /web/swc/activity-log?model=&id= — sys.audit rows for the Log tab only (not mail.message). +func SwcActivityLogHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + model := strings.TrimSpace(r.URL.Query().Get("model")) + idRaw := strings.TrimSpace(r.URL.Query().Get("id")) + recordID, _ := strconv.ParseInt(idRaw, 10, 64) + if model == "" || recordID <= 0 { + http.Error(w, "model and id required", http.StatusBadRequest) + return + } + ctx := r.Context() + uid := AuthenticatedUserID(r) + if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil { + http.Error(w, "access denied", http.StatusForbidden) + return + } + if err := orm.CheckModelAccess(ctx, uid, "sys.audit", "read"); err != nil { + http.Error(w, "access denied", http.StatusForbidden) + return + } + if _, err := orm.SearchOne(ctx, model, map[string]interface{}{"id": int(recordID)}); err != nil { + http.NotFound(w, r) + return + } + domain := [][]interface{}{ + {"model", "=", model}, + {"res_id", "=", int(recordID)}, + } + rows, err := orm.SearchPage(ctx, "sys.audit", domain, 40, 0, "create_date DESC") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + out := swcActivityLogPayload{Items: make([]swcActivityLogItem, 0, len(rows))} + for _, row := range rows { + action := strings.TrimSpace(orm.AsString(row["action"])) + detail := strings.TrimSpace(orm.AsString(row["detail"])) + resModel := strings.TrimSpace(orm.AsString(row["model"])) + if resModel == "" { + resModel = model + } + body := orm.FormatAuditLogBody(ctx, resModel, action, + orm.AsString(row["before_json"]), orm.AsString(row["after_json"]), detail) + author := "System" + if userID, ok := orm.CoerceInt64(row["user_id"]); ok && userID > 0 { + if u, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": int(userID)}); err == nil { + if nm := strings.TrimSpace(orm.AsString(u["name"])); nm != "" { + author = nm + } else if lg := strings.TrimSpace(orm.AsString(u["login"])); lg != "" { + author = lg + } + } + } + when := strings.TrimSpace(orm.AsString(row["create_date"])) + meta := author + if when != "" { + meta = author + " · " + when + } + out.Items = append(out.Items, swcActivityLogItem{ + Meta: meta, + Body: body, + Action: action, + CreateDate: when, + }) + } + out.HasLog = len(out.Items) > 0 + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(out) +} diff --git a/core/server/web/swc_direct.go b/core/server/web/swc_direct.go new file mode 100644 index 00000000..287e141d --- /dev/null +++ b/core/server/web/swc_direct.go @@ -0,0 +1,182 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "path/filepath" + "strconv" + "strings" + + "sumeru/addons/im" + "sumeru/core/orm" +) + +const ( + swcDirectUsersRoute = "/web/swc/direct/users" + swcDirectConversationsRoute = "/web/swc/direct/conversations" + swcDirectThreadRoute = "/web/swc/direct/thread" + swcDirectPostRoute = "/web/swc/direct/post" + swcDirectUploadRoute = "/web/swc/direct/upload" +) + +func registerSwcDirectRoutes() { + registerSession(http.MethodGet, swcDirectUsersRoute, SwcDirectUsersHandler) + registerSession(http.MethodGet, swcDirectConversationsRoute, SwcDirectConversationsHandler) + registerSession(http.MethodGet, swcDirectThreadRoute, SwcDirectThreadHandler) + registerSession(http.MethodPost, swcDirectPostRoute, SwcDirectPostHandler) + registerSession(http.MethodPost, swcDirectUploadRoute, SwcDirectUploadHandler) +} + +// SwcDirectUsersHandler GET /web/swc/direct/users?q= — internal user search for Messages tab. +func SwcDirectUsersHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + if !im.IMEnabled(r.Context()) { + http.Error(w, "messages disabled", http.StatusForbidden) + return + } + uid := AuthenticatedUserID(r) + rows, err := im.SearchInternalUsers(r.Context(), uid, r.URL.Query().Get("q"), 20) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + type userItem struct { + ID int `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + } + out := make([]userItem, 0, len(rows)) + for _, row := range rows { + id, _ := orm.CoerceInt64(row["id"]) + out = append(out, userItem{ + ID: int(id), + Name: strings.TrimSpace(orm.AsString(row["name"])), + Login: strings.TrimSpace(orm.AsString(row["login"])), + }) + } + writeJSONResponse(w, map[string]interface{}{"users": out}) +} + +func SwcDirectConversationsHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + if !im.IMEnabled(r.Context()) { + http.Error(w, "messages disabled", http.StatusForbidden) + return + } + uid := AuthenticatedUserID(r) + items, err := im.ListDirectConversations(r.Context(), uid, 30) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + writeJSONResponse(w, map[string]interface{}{"conversations": items}) +} + +func SwcDirectThreadHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + if !im.IMEnabled(r.Context()) { + http.Error(w, "messages disabled", http.StatusForbidden) + return + } + peerID, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("userId"))) + uid := AuthenticatedUserID(r) + msgs, err := im.ListDirectThread(r.Context(), uid, peerID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, map[string]interface{}{"messages": msgs}) +} + +func SwcDirectPostHandler(w http.ResponseWriter, r *http.Request) { + if !requireLoginAndPOST(w, r) { + return + } + if !im.IMEnabled(r.Context()) { + http.Error(w, "messages disabled", http.StatusForbidden) + return + } + if !ParsePostForm(w, r) { + return + } + recipientID, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("recipient_id"))) + body := strings.TrimSpace(r.PostFormValue("body")) + uid := AuthenticatedUserID(r) + id, err := im.PostDirectMessage(r.Context(), uid, recipientID, body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, map[string]interface{}{"id": id}) +} + +func SwcDirectUploadHandler(w http.ResponseWriter, r *http.Request) { + if !requireLoginMultipartPost(w, r, maxImportBodyBytes) { + return + } + if !im.IMEnabled(r.Context()) { + http.Error(w, "messages disabled", http.StatusForbidden) + return + } + msgID, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("message_id"))) + if msgID <= 0 { + http.Error(w, "message_id required", http.StatusBadRequest) + return + } + uid := AuthenticatedUserID(r) + ctx := r.Context() + if err := orm.CheckModelAccess(ctx, uid, im.MessageModel, "read"); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + msg, err := orm.SearchOne(ctx, im.MessageModel, map[string]interface{}{"id": msgID}) + if err != nil { + http.Error(w, "message not found", http.StatusNotFound) + return + } + sid, _ := orm.CoerceInt64(msg["sender_id"]) + rid, _ := orm.CoerceInt64(msg["recipient_id"]) + if int(sid) != uid && int(rid) != uid { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + upload, header, err := r.FormFile(importFileField) + if err != nil { + http.Error(w, "file required", http.StatusBadRequest) + return + } + defer upload.Close() + content, err := io.ReadAll(upload) + if err != nil || len(content) == 0 { + http.Error(w, "empty file", http.StatusBadRequest) + return + } + name := strings.TrimSpace(header.Filename) + if name == "" { + name = "upload" + } + name = filepath.Base(name) + attID, err := orm.CreateBinaryAttachment(ctx, orm.CreateBinaryAttachmentInput{ + Name: name, + ResModel: im.MessageModel, + ResID: msgID, + Data: content, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": attID, + "name": name, + "url": "/web/content/" + strconv.Itoa(attID), + }) +} diff --git a/core/server/web/swc_notifications.go b/core/server/web/swc_notifications.go index 9fdd807e..37562af1 100644 --- a/core/server/web/swc_notifications.go +++ b/core/server/web/swc_notifications.go @@ -2,10 +2,6 @@ package web import ( "net/http" - "strings" - "time" - - "sumeru/core/orm" ) const ( @@ -34,51 +30,9 @@ func SwcNotificationsHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return } - uid := AuthenticatedUserID(r) - ctx := r.Context() - if err := orm.CheckModelAccess(ctx, uid, "mail.notification", "read"); err != nil { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - unreadOnly := strings.TrimSpace(r.URL.Query().Get("unread")) == "1" - domain := [][]interface{}{{"user_id", "=", uid}} - if unreadOnly { - domain = append(domain, []interface{}{"is_read", "=", false}) - } - rows, err := orm.SearchPage(ctx, "mail.notification", domain, 50, 0, "create_date DESC") - if err != nil { - http.Error(w, "load failed", http.StatusInternalServerError) - return - } - items := make([]swcNotificationItem, 0, len(rows)) - unread := 0 - for _, row := range rows { - msgID := int(coerceFloat(row["message_id"])) - body, author := "", "" - if msgID > 0 { - if msg, err := orm.SearchOne(ctx, "mail.message", map[string]interface{}{"id": msgID}); err == nil && msg != nil { - body, _ = msg["body"].(string) - author, _ = msg["author"].(string) - } - } - isRead, _ := row["is_read"].(bool) - if !isRead { - unread++ - } - items = append(items, swcNotificationItem{ - ID: int(coerceFloat(row["id"])), - MessageID: msgID, - Body: body, - Author: author, - RecordModel: notifStringField(row["record_model"]), - RecordID: int(coerceFloat(row["record_id"])), - IsRead: isRead, - CreateDate: notifStringField(row["create_date"]), - }) - } writeJSONResponse(w, map[string]interface{}{ - "items": items, - "unread": unread, + "items": []swcNotificationItem{}, + "unread": 0, }) } @@ -90,61 +44,5 @@ func SwcNotificationsMarkReadHandler(w http.ResponseWriter, r *http.Request) { if !ParsePostForm(w, r) { return } - uid := AuthenticatedUserID(r) - ctx := r.Context() - if err := orm.CheckModelAccess(ctx, uid, "mail.notification", "write"); err != nil { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - markAll := strings.TrimSpace(r.PostFormValue("all")) == "1" - now := time.Now().UTC().Format(time.RFC3339) - vals := map[string]interface{}{"is_read": true, "read_date": now} - if markAll { - rows, _ := orm.Search(ctx, "mail.notification", [][]interface{}{ - {"user_id", "=", uid}, - {"is_read", "=", false}, - }) - for _, row := range rows { - id := int(coerceFloat(row["id"])) - _, _ = orm.Update(ctx, "mail.notification", [][]interface{}{{"id", "=", id}}, vals) - } - writeJSONResponse(w, map[string]interface{}{"ok": true}) - return - } - id := int(coerceFloat(r.PostFormValue("id"))) - if id <= 0 { - http.Error(w, "id required", http.StatusBadRequest) - return - } - rec, err := orm.SearchOne(ctx, "mail.notification", map[string]interface{}{"id": id}) - if err != nil || rec == nil || int(coerceFloat(rec["user_id"])) != uid { - http.Error(w, "not found", http.StatusNotFound) - return - } - _, err = orm.Update(ctx, "mail.notification", [][]interface{}{{"id", "=", id}}, vals) - if err != nil { - http.Error(w, "update failed", http.StatusInternalServerError) - return - } writeJSONResponse(w, map[string]interface{}{"ok": true}) } - -func coerceFloat(v interface{}) float64 { - switch t := v.(type) { - case float64: - return t - case int: - return float64(t) - case int64: - return float64(t) - default: - return 0 - } -} - -func notifStringField(v interface{}) string { - if s, ok := v.(string); ok { - return s - } - return "" -} diff --git a/core/server/web/swc_payload.go b/core/server/web/swc_payload.go index 5a371ec9..71a3957f 100644 --- a/core/server/web/swc_payload.go +++ b/core/server/web/swc_payload.go @@ -74,9 +74,22 @@ func buildSwcWorkspacePayload( payload.UserSecurity = &us } } + applyActivityRecordFlags(ctx, &payload, resolved.selectedMode, viewRecord.RecordID, viewRecord.ResModel) return payload } +func applyActivityRecordFlags(ctx context.Context, payload *swcmeta.WorkspacePayload, viewMode string, recordID int, model string) { + _ = viewMode + if payload == nil || recordID <= 0 || model == "" { + return + } + uid := orm.SecurityUID(ctx) + if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil { + return + } + payload.RecordLogEligible = orm.CheckModelAccess(ctx, uid, "sys.audit", "read") == nil +} + func serializeSwcViewTabs(tabs []render.ViewSwitchTab) []swcmeta.ViewTab { out := make([]swcmeta.ViewTab, 0, len(tabs)) for _, t := range tabs { diff --git a/core/server/web/swc_workspace.go b/core/server/web/swc_workspace.go index f7208804..7b749310 100644 --- a/core/server/web/swc_workspace.go +++ b/core/server/web/swc_workspace.go @@ -11,8 +11,9 @@ func registerSwcRoutes() { registerSession(http.MethodGet, swcWorkspaceRoute, SwcWorkspaceHandler) registerSwcSavedSearchRoutes() registerSwcBusRoute() - registerSwcChatterRoute() + registerSwcActivityLogRoute() registerSwcNotificationRoutes() + registerSwcDirectRoutes() registerSwcImportRoute() } diff --git a/core/server/web/testexports.go b/core/server/web/testexports.go index d7cc3c3d..27dc6fc0 100644 --- a/core/server/web/testexports.go +++ b/core/server/web/testexports.go @@ -7,6 +7,7 @@ import ( "time" "sumeru/core/engine/render" + "sumeru/core/engine/swcmeta" "sumeru/core/orm" "sumeru/core/report" ) @@ -35,7 +36,6 @@ const ( TestLoginRoute = loginRoute TestLogoutRoute = logoutRoute TestPinnedAppsRoute = pinnedAppsRoute - TestChatterPostRoute = chatterPostRoute TestCompanySwitchRoute = companySwitchRoute TestModuleActionRoute = moduleActionRoute TestAPIRPCRoute = apiRPCRoute @@ -77,7 +77,6 @@ var ( TestSettingsHubStylesheetURL = settingsHubStylesheetURL TestSettingsHubScriptURL = settingsHubScriptURL TestMaxRPCBodyBytes int64 = maxRPCBodyBytes - TestMaxChatterBodyRunes = maxChatterBodyRunes TestSetupRateLimitWindow = setupRateLimitWindow TestSetupRateLimitMax = setupRateLimitMax ) @@ -260,14 +259,6 @@ func WorkspaceListURL(actionID, menuID string) string { return workspaceListURL( func FormOrQueryValue(r *http.Request, field string) string { return formOrQueryValue(r, field) } -func ParseChatterPostForm(r *http.Request) chatterPostForm { return parseChatterPostForm(r) } - -func ChatterBodyTooLong(body string) bool { return chatterBodyTooLong(body) } - -func ParseChatterRecordID(recordIDRaw string) (int64, error) { - return parseChatterRecordID(recordIDRaw) -} - func CoerceCSVValue(raw string) interface{} { return coerceCSVValue(raw) } func ImportableRowValues(header []string, record []string, allowedFields map[string]struct{}) map[string]interface{} { @@ -427,6 +418,30 @@ func SwcImportHandlerForTest(w http.ResponseWriter, r *http.Request) { SwcImportHandler(w, r) } +func SwcActivityLogHandlerForTest(w http.ResponseWriter, r *http.Request) { + SwcActivityLogHandler(w, r) +} + +func SwcDirectUsersHandlerForTest(w http.ResponseWriter, r *http.Request) { + SwcDirectUsersHandler(w, r) +} + +func SwcDirectThreadHandlerForTest(w http.ResponseWriter, r *http.Request) { + SwcDirectThreadHandler(w, r) +} + +func SwcDirectConversationsHandlerForTest(w http.ResponseWriter, r *http.Request) { + SwcDirectConversationsHandler(w, r) +} + +func SwcDirectPostHandlerForTest(w http.ResponseWriter, r *http.Request) { + SwcDirectPostHandler(w, r) +} + +func ApplyActivityRecordFlagsForTest(ctx context.Context, payload *swcmeta.WorkspacePayload, viewMode string, recordID int, model string) { + applyActivityRecordFlags(ctx, payload, viewMode, recordID, model) +} + func ImportWizardPageHandlerForTest(w http.ResponseWriter, r *http.Request) { ImportWizardPageHandler(w, r) } @@ -435,10 +450,6 @@ func ExportAsyncHandlerForTest(w http.ResponseWriter, r *http.Request) { ExportAsyncHandler(w, r) } -func ChatterUploadHandlerForTest(w http.ResponseWriter, r *http.Request) { - ChatterUploadHandler(w, r) -} - // SetTestSessionUserIDForTest overrides SessionUserID for handler tests. func SetTestSessionUserIDForTest(userID int) { testSessionUserIDOverride = userID } diff --git a/core/server/web/web_constants.go b/core/server/web/web_constants.go index e20ff2bc..dea0a59d 100644 --- a/core/server/web/web_constants.go +++ b/core/server/web/web_constants.go @@ -108,7 +108,6 @@ const ( // Flash query parameter appended to redirects after form actions. const flashMessageParam = "msg" -const fieldErrorsParam = "field_errors" const saveOKCreatedMsg = "save_ok_created" const saveOKUpdatedMsg = "save_ok_updated" @@ -288,16 +287,7 @@ const ( actionIDField = "action" ) -// Chatter POST route, form fields, and limits. -const ( - chatterPostRoute = "/web/chatter/post" - recordModelField = "model" - chatterRecordIDField = "res_id" - chatterBodyField = "body" - mailMessageModel = "mail.message" - maxChatterBodyRunes = 10000 - chatterDefaultAuthor = "User" -) +const recordModelField = "model" // Apps page routes, templates, and ORM model. const ( diff --git a/core/swc/src/main.ts b/core/swc/src/main.ts index 927a4d7c..f7dffe12 100644 --- a/core/swc/src/main.ts +++ b/core/swc/src/main.ts @@ -126,7 +126,7 @@ function bootstrap(): void { initDebugManager(boot, { dialog: env.services.dialog, notification: env.services.notification }); mountDebugEnvironment(boot); void updateDebugDrawer(boot); - initShellChrome(boot, env.services.http, env.services.bus); + initShellChrome(boot, env.services.http, env.services.bus, env); initAppLauncher(boot, env.services.action, env.services.command); const mountEl = document.getElementById("swc-workspace"); diff --git a/core/swc/src/shell/activity-context.ts b/core/swc/src/shell/activity-context.ts new file mode 100644 index 00000000..3cb03309 --- /dev/null +++ b/core/swc/src/shell/activity-context.ts @@ -0,0 +1,40 @@ +import type { SwcWorkspacePayload } from "../types/workspace.js"; + +export const ACTIVITY_CONTEXT = "activity.context"; + +export interface ActivityContextPayload { + model: string; + recordId: number; + recordMessagesEligible: boolean; + recordLogEligible: boolean; +} + +export function activityContextFromWorkspace(payload: SwcWorkspacePayload): ActivityContextPayload { + return { + model: payload.model, + recordId: payload.recordId, + recordMessagesEligible: payload.recordMessagesEligible === true, + recordLogEligible: payload.recordLogEligible === true, + }; +} + +export function emptyActivityContext(): ActivityContextPayload { + return { + model: "", + recordId: 0, + recordMessagesEligible: false, + recordLogEligible: false, + }; +} + +export function messagesEmptyStateHTML(): string { + return `
+ +

Messages

+

Search for an internal user to start a direct conversation.

+
`; +} diff --git a/core/swc/src/shell/activity-log.ts b/core/swc/src/shell/activity-log.ts new file mode 100644 index 00000000..7857de4d --- /dev/null +++ b/core/swc/src/shell/activity-log.ts @@ -0,0 +1,85 @@ +import { RECORD_UPDATED, SWC_API_BASE } from "../constants/routes.js"; +import type { SwcEnv } from "../runtime/env.js"; +import { ACTIVITY_CONTEXT, type ActivityContextPayload } from "./activity-context.js"; +import { setActivityLogTabVisible } from "./activity-panel.js"; + +interface ActivityLogItem { + meta: string; + body: string; +} + +interface ActivityLogResponse { + items: ActivityLogItem[]; + hasLog?: boolean; +} + +export function initActivityLog(env: SwcEnv): void { + const host = document.getElementById("sum-activity-pane-log"); + if (!host) return; + + let current: ActivityContextPayload | null = null; + + const clearLogPane = (): void => { + host.innerHTML = ""; + host.hidden = true; + setActivityLogTabVisible(false); + }; + + const renderItems = (items: ActivityLogItem[]): void => { + const rows = items + .map( + (item) => `
+ +
${escapeHtml(item.meta)}
${escapeHtml(item.body)}
+
`, + ) + .join(""); + host.innerHTML = `
${rows}
`; + }; + + const loadLog = async (ctx: ActivityContextPayload): Promise => { + if (!ctx.recordLogEligible || ctx.recordId <= 0 || !ctx.model) { + clearLogPane(); + return; + } + try { + const base = env.bootstrap.swcApiBase || SWC_API_BASE; + const data = await env.services.http.getJSON( + `${base}/activity-log?model=${encodeURIComponent(ctx.model)}&id=${ctx.recordId}`, + ); + const items = data.items ?? []; + const hasLog = data.hasLog === true || items.length > 0; + if (!hasLog) { + clearLogPane(); + return; + } + setActivityLogTabVisible(true); + renderItems(items); + } catch { + clearLogPane(); + } + }; + + env.services.bus.subscribe(ACTIVITY_CONTEXT, (payload) => { + current = payload as ActivityContextPayload; + void loadLog(current); + }); + + env.services.bus.subscribe(RECORD_UPDATED, (payload) => { + const msg = payload as { model?: string; id?: number; recordId?: number }; + if (!current?.recordLogEligible) return; + const rid = msg.id ?? msg.recordId; + if (msg.model !== current.model || rid !== current.recordId) return; + void loadLog(current); + }); + + clearLogPane(); +} + +function escapeHtml(raw: string): string { + return raw + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/core/swc/src/shell/activity-messages.ts b/core/swc/src/shell/activity-messages.ts new file mode 100644 index 00000000..a540a3ab --- /dev/null +++ b/core/swc/src/shell/activity-messages.ts @@ -0,0 +1,17 @@ +import type { SwcEnv } from "../runtime/env.js"; +import { InternalMessagesPanel } from "./internal-messages-panel.js"; + +/** Messages tab: internal user search and P2P chat (not record chatter or Log). */ +export function initActivityMessages(env: SwcEnv): void { + const host = document.getElementById("sum-activity-pane-messages"); + if (!host) return; + + host.innerHTML = ""; + const mountEl = document.createElement("div"); + mountEl.className = "sum-activity-messages-mount"; + host.appendChild(mountEl); + + const panel = new InternalMessagesPanel({}, env); + panel.callSetup(); + mountEl.replaceChildren(panel.render()); +} diff --git a/core/swc/src/shell/activity-panel.ts b/core/swc/src/shell/activity-panel.ts index cfee0350..643500cf 100644 --- a/core/swc/src/shell/activity-panel.ts +++ b/core/swc/src/shell/activity-panel.ts @@ -30,27 +30,49 @@ function paintActivityRevealIcon(): void { if (reveal && !reveal.firstChild) reveal.innerHTML = CHEVRON_LEFT; } +function activateActivityTab(name: string): void { + const panes: Record = { + messages: "sum-activity-pane-messages", + log: "sum-activity-pane-log", + }; + document.querySelectorAll("[data-sum-activity-tab]").forEach((t) => { + const on = t.getAttribute("data-sum-activity-tab") === name; + t.classList.toggle("is-active", on); + t.setAttribute("aria-selected", on ? "true" : "false"); + }); + for (const [key, id] of Object.entries(panes)) { + const el = document.getElementById(id); + if (el) el.hidden = key !== name; + } +} + function initActivityTabs(): void { document.querySelectorAll("[data-sum-activity-tab]").forEach((tab) => { tab.addEventListener("click", () => { const name = tab.getAttribute("data-sum-activity-tab"); - const panes: Record = { - messages: "sum-activity-pane-messages", - log: "sum-activity-pane-log", - }; - document.querySelectorAll("[data-sum-activity-tab]").forEach((t) => { - const on = t.getAttribute("data-sum-activity-tab") === name; - t.classList.toggle("is-active", on); - t.setAttribute("aria-selected", on ? "true" : "false"); - }); - for (const [key, id] of Object.entries(panes)) { - const el = document.getElementById(id); - if (el) el.hidden = key !== name; - } + if (name) activateActivityTab(name); }); }); } +/** Show or hide the Log tab; switches to Messages when Log is hidden while active. */ +export function setActivityLogTabVisible(visible: boolean): void { + const logTab = document.querySelector('[data-sum-activity-tab="log"]'); + if (logTab) { + logTab.hidden = !visible; + logTab.setAttribute("aria-hidden", visible ? "false" : "true"); + } + const logPane = document.getElementById("sum-activity-pane-log"); + if (logPane && !visible) { + logPane.hidden = true; + logPane.innerHTML = ""; + } + if (!visible) { + const logActive = logTab?.classList.contains("is-active"); + if (logActive) activateActivityTab("messages"); + } +} + function initActivityResizer(shell: HTMLElement): void { const resizer = document.getElementById("sum-activity-resizer"); if (!resizer) return; diff --git a/core/swc/src/shell/internal-messages-panel.ts b/core/swc/src/shell/internal-messages-panel.ts new file mode 100644 index 00000000..bddec21e --- /dev/null +++ b/core/swc/src/shell/internal-messages-panel.ts @@ -0,0 +1,283 @@ +import { SwcComponent } from "../runtime/component.js"; +import { html } from "../template/html.js"; +import { SWC_API_BASE } from "../constants/routes.js"; +import { inputValueFromEvent } from "../widgets/field-events.js"; + +interface DirectUser { + id: number; + name: string; + login: string; +} + +interface DirectConversation { + userId: number; + name: string; + preview?: string; + lastMessage?: string; +} + +interface DirectAttachment { + id: number; + name: string; + url: string; +} + +interface DirectMessage { + id: number; + body: string; + createDate: string; + outgoing: boolean; + attachments?: DirectAttachment[]; +} + +export class InternalMessagesPanel extends SwcComponent { + private search = ""; + private searchResults: DirectUser[] = []; + private conversations: DirectConversation[] = []; + private peerId = 0; + private peerName = ""; + private thread: DirectMessage[] = []; + private draft = ""; + private loading = false; + private posting = false; + private enabled = true; + private disabledReason = ""; + private searchTimer: ReturnType | null = null; + + override setup(): void { + void this.refreshConversations(); + } + + private apiBase(): string { + return this.env.bootstrap.swcApiBase || SWC_API_BASE; + } + + private async refreshConversations(): Promise { + try { + const data = await this.env.services.http.getJSON<{ conversations: DirectConversation[] }>( + `${this.apiBase()}/direct/conversations`, + ); + this.conversations = data.conversations ?? []; + this.enabled = true; + this.rerender(); + } catch { + this.enabled = false; + this.disabledReason = "Internal messages are unavailable or disabled for this company."; + this.rerender(); + } + } + + private async runSearch(): Promise { + const q = this.search.trim(); + if (q.length < 1) { + this.searchResults = []; + this.rerender(); + return; + } + try { + const data = await this.env.services.http.getJSON<{ users: DirectUser[] }>( + `${this.apiBase()}/direct/users?q=${encodeURIComponent(q)}`, + ); + this.searchResults = data.users ?? []; + } catch { + this.searchResults = []; + } + this.rerender(); + } + + private scheduleSearch(): void { + if (this.searchTimer) clearTimeout(this.searchTimer); + this.searchTimer = setTimeout(() => void this.runSearch(), 250); + } + + private async openPeer(userId: number, name: string): Promise { + this.peerId = userId; + this.peerName = name; + this.search = ""; + this.searchResults = []; + await this.loadThread(); + } + + private async loadThread(): Promise { + if (this.peerId <= 0) return; + this.loading = true; + this.rerender(); + try { + const data = await this.env.services.http.getJSON<{ messages: DirectMessage[] }>( + `${this.apiBase()}/direct/thread?userId=${this.peerId}`, + ); + this.thread = data.messages ?? []; + } finally { + this.loading = false; + this.rerender(); + } + } + + private async postMessage(): Promise { + const body = this.draft.trim(); + if (!body || this.peerId <= 0) return; + this.posting = true; + this.rerender(); + try { + await this.env.services.http.postForm(`${this.apiBase()}/direct/post`, { + recipient_id: String(this.peerId), + body, + }); + this.draft = ""; + await this.loadThread(); + await this.refreshConversations(); + } finally { + this.posting = false; + this.rerender(); + } + } + + private async uploadFile(file: File): Promise { + if (this.peerId <= 0) return; + let messageId = 0; + if (this.draft.trim()) { + const res = await this.env.services.http.postForm(`${this.apiBase()}/direct/post`, { + recipient_id: String(this.peerId), + body: this.draft.trim(), + }); + const parsed = (await res.json()) as { id?: number }; + messageId = parsed.id ?? 0; + this.draft = ""; + } else if (this.thread.length > 0) { + messageId = this.thread[this.thread.length - 1].id; + } else { + const res = await this.env.services.http.postForm(`${this.apiBase()}/direct/post`, { + recipient_id: String(this.peerId), + body: " ", + }); + const parsed = (await res.json()) as { id?: number }; + messageId = parsed.id ?? 0; + } + if (messageId <= 0) return; + const form = new FormData(); + form.set("message_id", String(messageId)); + form.set("file", file); + await this.env.services.http.postMultipart("/web/swc/direct/upload", form); + await this.loadThread(); + await this.refreshConversations(); + this.rerender(); + } + + override template() { + if (!this.enabled) { + return html`
+

Messages

+

${this.disabledReason}

+
`; + } + + const listItems = + this.searchResults.length > 0 + ? this.searchResults.map( + (u) => html`
  • + +
  • `, + ) + : this.conversations.map( + (c) => html`
  • + +
  • `, + ); + + return html`
    +
    +
    + { + this.search = inputValueFromEvent(event); + this.scheduleSearch(); + this.rerender(); + }} + /> +
      ${listItems}
    +
    +
    + ${this.peerId <= 0 + ? html`
    +

    Direct messages

    +

    Search for a colleague or pick a recent conversation.

    +
    ` + : html`
    ${this.peerName}
    +
    + ${this.loading + ? html`

    Loading…

    ` + : this.thread.length === 0 + ? html`

    No messages yet. Say hello.

    ` + : this.thread.map( + (m) => html`
    +
    + ${m.createDate} +
    +
    ${m.body}
    + ${(m.attachments ?? []).length > 0 + ? html`
      + ${(m.attachments ?? []).map( + (a) => html`
    • ${a.name}
    • `, + )} +
    ` + : ""} +
    `, + )} +
    +
    +
    + +
    + + +
    +

    Internal users only · not shown on record Log

    +
    +
    `} +
    +
    +
    `; + } +} diff --git a/core/swc/src/shell/shell-chrome.ts b/core/swc/src/shell/shell-chrome.ts index c8ed5c9e..3861143d 100644 --- a/core/swc/src/shell/shell-chrome.ts +++ b/core/swc/src/shell/shell-chrome.ts @@ -1,7 +1,11 @@ import type { HttpService } from "../services/http.js"; import { NotificationService } from "../services/notification.js"; import type { SwcBootstrap } from "../types/bootstrap.js"; +import type { SwcEnv } from "../runtime/env.js"; import { initActivityPanel } from "./activity-panel.js"; +import { initActivityMessages } from "./activity-messages.js"; +import { initActivityLog } from "./activity-log.js"; +import { ACTIVITY_CONTEXT, emptyActivityContext } from "./activity-context.js"; import { initHomeDashboard } from "./home-dashboard.js"; import { initPinnedApps } from "./pinned-apps.js"; import { initSidebar } from "./sidebar.js"; @@ -11,7 +15,7 @@ import { initBreadcrumbNavigation } from "./breadcrumb-sync.js"; import { initNotificationBell } from "./notification-bell.js"; import type { BusService } from "../services/bus.js"; -export function initShellChrome(boot: SwcBootstrap, http: HttpService, bus?: BusService): void { +export function initShellChrome(boot: SwcBootstrap, http: HttpService, bus?: BusService, env?: SwcEnv): void { const shell = document.getElementById("sum-shell"); if (!shell) return; @@ -21,6 +25,11 @@ export function initShellChrome(boot: SwcBootstrap, http: HttpService, bus?: Bus if (boot.activityEnabled) { initActivityPanel(shell); + if (env) { + initActivityMessages(env); + initActivityLog(env); + env.services.bus.emit(ACTIVITY_CONTEXT, emptyActivityContext()); + } } initPinnedApps(http, boot.pinnedApps ?? []); diff --git a/core/swc/tests/views/activity-panel.test.ts b/core/swc/tests/views/activity-panel.test.ts index a3fe59f8..c37e9a16 100644 --- a/core/swc/tests/views/activity-panel.test.ts +++ b/core/swc/tests/views/activity-panel.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { applyActivityHidden } from "../../src/shell/activity-panel.js"; +import { applyActivityHidden, setActivityLogTabVisible } from "../../src/shell/activity-panel.js"; describe("activity-panel", () => { beforeEach(() => { document.body.innerHTML = `
    - +
    @@ -22,4 +27,13 @@ describe("activity-panel", () => { expect(shell.classList.contains("sum-shell--activity-hidden")).toBe(false); expect((document.getElementById("sum-activity-reveal") as HTMLButtonElement).hidden).toBe(true); }); + + it("setActivityLogTabVisible toggles log tab and pane", () => { + const logTab = document.querySelector('[data-sum-activity-tab="log"]') as HTMLElement; + setActivityLogTabVisible(true); + expect(logTab.hidden).toBe(false); + setActivityLogTabVisible(false); + expect(logTab.hidden).toBe(true); + expect((document.getElementById("sum-activity-pane-log") as HTMLElement).innerHTML).toBe(""); + }); }); diff --git a/test/addons/audit/audit_cleanup_test.go b/test/addons/audit/audit_cleanup_test.go new file mode 100644 index 00000000..c7eddbda --- /dev/null +++ b/test/addons/audit/audit_cleanup_test.go @@ -0,0 +1,37 @@ +package audit_test + +import ( + "testing" + + "sumeru/addons/audit" +) + +func TestRetentionDaysFromRecord(t *testing.T) { + d, err := audit.RetentionDaysFromRecordForTest(map[string]interface{}{"retention_preset": "7_days"}) + if err != nil || d != 7 { + t.Fatalf("7_days: d=%d err=%v", d, err) + } + d, err = audit.RetentionDaysFromRecordForTest(map[string]interface{}{"retention_preset": "custom", "retention_days": 14}) + if err != nil || d != 14 { + t.Fatalf("custom: d=%d err=%v", d, err) + } + _, err = audit.RetentionDaysFromRecordForTest(map[string]interface{}{"retention_preset": "custom"}) + if err == nil { + t.Fatal("expected custom days error") + } + d, err = audit.RetentionDaysFromRecordForTest(map[string]interface{}{"retention_preset": "30_days"}) + if err != nil || d != 30 { + t.Fatalf("30_days: d=%d err=%v", d, err) + } + _, err = audit.RetentionDaysFromRecordForTest(map[string]interface{}{"retention_preset": "never"}) + if err == nil { + t.Fatal("expected unknown preset error") + } + _, err = audit.RetentionDaysFromRecordForTest(map[string]interface{}{ + "retention_preset": "custom", + "retention_days": 3651, + }) + if err == nil { + t.Fatal("expected retention days too large") + } +} diff --git a/test/addons/audit/audit_export_test.go b/test/addons/audit/audit_export_test.go new file mode 100644 index 00000000..b3e90772 --- /dev/null +++ b/test/addons/audit/audit_export_test.go @@ -0,0 +1,69 @@ +package audit_test + +import ( + "testing" + + "sumeru/addons/audit" +) + +func TestAuditRowFromMap(t *testing.T) { + row := audit.AuditRowFromMapForTest(map[string]interface{}{ + "id": 9, "action": "write", "model": "core.user", "res_id": 3, "user_id": 1, + "create_date": "2026-01-01T00:00:00Z", "detail": "ok", + }) + if row.ID != 9 || row.Model != "core.user" || row.Detail != "ok" { + t.Fatalf("row: %+v", row) + } +} + +func TestAuditPlainNDJSONMime(t *testing.T) { + raw := []byte("{}\n") + out, mime, err := audit.MaybeGzipAuditForTest(false, raw) + if err != nil || mime != "application/x-ndjson" || string(out) != string(raw) { + t.Fatalf("plain: mime=%s err=%v", mime, err) + } +} + +func TestAuditGzipExport(t *testing.T) { + raw, err := audit.EncodeAuditJSONLForTest([]audit.AuditExportRowForTest{{ID: 1, Action: "read"}}) + if err != nil { + t.Fatal(err) + } + gz, mime, err := audit.MaybeGzipAuditForTest(true, raw) + if err != nil || mime != "application/gzip" || len(gz) == 0 { + t.Fatalf("gzip: mime=%s err=%v", mime, err) + } + if audit.SHA256HexAuditForTest(gz) == "" { + t.Fatal("sha256") + } +} + +func TestAuditBoundsAndIDs(t *testing.T) { + rows := []audit.AuditExportRowForTest{ + {ID: 5, CreateDate: "2026-02-01T00:00:00Z"}, + {ID: 2, CreateDate: "2026-01-01T00:00:00Z"}, + } + from, to := audit.AuditPeriodBoundsForTest(rows) + if from != "2026-01-01T00:00:00Z" || to != "2026-02-01T00:00:00Z" { + t.Fatalf("bounds: %s %s", from, to) + } + minID, maxID := audit.AuditMinMaxIDForTest(rows) + if minID != 2 || maxID != 5 { + t.Fatalf("ids: %d %d", minID, maxID) + } +} + +func TestAuditJSONLRoundTrip(t *testing.T) { + rows := []audit.AuditExportRowForTest{ + {ID: 1, Action: "write", Model: "core.user", CreateDate: "2026-01-01T00:00:00Z"}, + {ID: 2, Action: "unlink", Model: "core.user", CreateDate: "2026-01-02T00:00:00Z"}, + } + raw, err := audit.EncodeAuditJSONLForTest(rows) + if err != nil { + t.Fatal(err) + } + decoded, err := audit.DecodeAuditJSONLForTest(raw) + if err != nil || len(decoded) != 2 || decoded[0].ID != 1 { + t.Fatalf("decode: %+v err=%v", decoded, err) + } +} diff --git a/test/addons/audit/audit_retention_test.go b/test/addons/audit/audit_retention_test.go new file mode 100644 index 00000000..82a791ec --- /dev/null +++ b/test/addons/audit/audit_retention_test.go @@ -0,0 +1,76 @@ +package audit_test + +import ( + "context" + "testing" + + "sumeru/addons/audit" +) + +func TestRetentionOptionsEmptyPolicyDefaults(t *testing.T) { + opts := audit.RetentionOptionsFromPolicyForTest(map[string]interface{}{}, false) + if opts.RetentionDays != 90 || opts.BatchSize != 0 { + t.Fatalf("opts: %+v", opts) + } +} + +func TestRetentionOptionsFromPolicyDefaults(t *testing.T) { + opts := audit.RetentionOptionsFromPolicyForTest(map[string]interface{}{ + "hot_retention_days": 45, + "delete_after_export": true, + "compress": true, + }, false) + if opts.RetentionDays != 45 || !opts.DeleteAfterExport || !opts.Compress { + t.Fatalf("opts: %+v", opts) + } +} + +func TestRetentionOptionsFromPolicyDryRun(t *testing.T) { + opts := audit.RetentionOptionsFromPolicyForTest(map[string]interface{}{ + "hot_retention_days": 90, + "dry_run_next": true, + }, false) + if !opts.DryRun { + t.Fatal("expected dry run") + } +} + +func TestAuditRetentionCronWithoutPolicy(t *testing.T) { + if err := audit.RunAuditRetentionCronForTest(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestRunAuditRetentionDryRun(t *testing.T) { + result, err := audit.RunAuditRetentionForTest(context.Background(), audit.RetentionOptions{ + RetentionDays: 30, + DryRun: true, + BatchSize: 100, + MaxRowsPerRun: 1000, + }) + if err != nil { + t.Skip("dry run needs database:", err) + } + if result.Summary == "" { + t.Fatal("expected summary") + } +} + +func TestRetentionOptionsLegalHold(t *testing.T) { + opts := audit.RetentionOptionsFromPolicyForTest(map[string]interface{}{ + "hot_retention_days": 90, + "legal_hold": true, + }, false) + if !opts.LegalHold { + t.Fatal("expected legal hold") + } +} + +func TestRetentionOptionsCompressDefault(t *testing.T) { + opts := audit.RetentionOptionsFromPolicyForTest(map[string]interface{}{ + "hot_retention_days": 90, + }, false) + if !opts.Compress { + t.Fatal("compress should default true") + } +} diff --git a/test/core/im/im_test.go b/test/core/im/im_test.go new file mode 100644 index 00000000..b41cc13f --- /dev/null +++ b/test/core/im/im_test.go @@ -0,0 +1,68 @@ +package im_test + +import ( + "context" + "testing" + + "sumeru/addons/im" + "sumeru/core/orm" +) + +func TestSortDirectMessages(t *testing.T) { + rows := []map[string]interface{}{ + {"create_date": "2024-01-02T00:00:00Z"}, + {"create_date": "2024-01-01T00:00:00Z"}, + } + im.SortDirectMessagesForTest(rows) + if orm.AsString(rows[0]["create_date"]) != "2024-01-01T00:00:00Z" { + t.Fatal("expected ascending sort") + } +} + +func TestUserDisplayName_noDB(t *testing.T) { + name := im.UserDisplayNameForTest(context.Background(), 99) + if name == "" { + t.Fatal("expected fallback name") + } +} + +func TestIMEnabled_defaultTrueWithoutDB(t *testing.T) { + if !im.IMEnabled(context.Background()) { + t.Fatal("default IM enabled") + } +} + +func TestPostDirectMessage_requiresBody(t *testing.T) { + _, err := im.PostDirectMessage(context.Background(), 1, 2, " ") + if err == nil { + t.Fatal("expected error for empty body") + } + _, err = im.PostDirectMessage(context.Background(), 1, 1, "hi") + if err == nil { + t.Fatal("expected error for self peer") + } +} + +func TestDirectHelpers_nilDB(t *testing.T) { + ctx := context.Background() + users, err := im.SearchInternalUsers(ctx, 1, "a", 5) + if err != nil || len(users) != 0 { + t.Fatalf("SearchInternalUsers nil db: %v %v", users, err) + } + convs, err := im.ListDirectConversations(ctx, 1, 5) + if err != nil || len(convs) != 0 { + t.Fatalf("ListDirectConversations nil db: %v %v", convs, err) + } + thread, err := im.ListDirectThread(ctx, 1, 2) + if err != nil || len(thread) != 0 { + t.Fatalf("ListDirectThread nil db: %v %v", thread, err) + } + _, err = im.ListDirectThread(ctx, 1, 1) + if err == nil { + t.Fatal("expected invalid peer") + } + _, err = im.PostDirectMessage(ctx, 1, 2, "hello") + if err == nil { + t.Fatal("expected error without message model registered") + } +} diff --git a/test/core/im/main_test.go b/test/core/im/main_test.go new file mode 100644 index 00000000..ad79dc35 --- /dev/null +++ b/test/core/im/main_test.go @@ -0,0 +1,11 @@ +package im_test + +import ( + "testing" + + _ "sumeru/addons/im/models" +) + +func TestMain(m *testing.M) { + m.Run() +} diff --git a/test/core/orm/audit_format_test.go b/test/core/orm/audit_format_test.go new file mode 100644 index 00000000..b26ad8de --- /dev/null +++ b/test/core/orm/audit_format_test.go @@ -0,0 +1,44 @@ +package orm_test + +import ( + "context" + "strings" + "testing" + + "sumeru/core/orm" +) + +func TestFormatAuditLogBodyWriteDiff(t *testing.T) { + ctx := context.Background() + before := `{"name":"Alice","active":true}` + after := `{"name":"Bob","active":true}` + body := orm.FormatAuditLogBodyForTest(ctx, "core.user", "write", before, after, "") + if !strings.Contains(body, "→") && !strings.Contains(body, "Bob") { + t.Fatalf("expected field diff, got %q", body) + } +} + +func TestFormatAuditLogBodyUnlink(t *testing.T) { + body := orm.FormatAuditLogBodyForTest(context.Background(), "core.user", "unlink", "", "", "") + if body != "Deleted record" { + t.Fatalf("got %q", body) + } +} + +func TestFormatAuditLogBodyCreate(t *testing.T) { + after := `{"name":"Acme"}` + body := orm.FormatAuditLogBodyForTest(context.Background(), "core.company", "create", "", after, "") + if body == "" || body == "Change recorded" { + t.Fatalf("expected create body, got %q", body) + } +} + +func TestFormatAuditLogBodyRedactsSensitive(t *testing.T) { + ctx := context.Background() + before := `{"password":"old"}` + after := `{"password":"new"}` + body := orm.FormatAuditLogBodyForTest(ctx, "core.user", "write", before, after, "") + if strings.Contains(strings.ToLower(body), "password") { + t.Fatalf("expected redacted field, got %q", body) + } +} diff --git a/test/core/server/web/swc_activity_log_test.go b/test/core/server/web/swc_activity_log_test.go new file mode 100644 index 00000000..b090648e --- /dev/null +++ b/test/core/server/web/swc_activity_log_test.go @@ -0,0 +1,29 @@ +package web_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "sumeru/core/server/web" +) + +func TestSwcActivityLogHandlerRequiresParams(t *testing.T) { + web.SetTestSessionUserIDForTest(1) + t.Cleanup(web.ResetTestSessionUserIDForTest) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/web/swc/activity-log", nil) + web.SwcActivityLogHandlerForTest(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestSwcActivityLogHandlerRequiresLogin(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/web/swc/activity-log?model=core.user&id=1", nil) + web.SwcActivityLogHandlerForTest(rec, req) + if rec.Code != http.StatusFound && rec.Code != http.StatusForbidden { + t.Fatalf("status=%d", rec.Code) + } +} diff --git a/test/core/server/web/swc_direct_test.go b/test/core/server/web/swc_direct_test.go new file mode 100644 index 00000000..2088f526 --- /dev/null +++ b/test/core/server/web/swc_direct_test.go @@ -0,0 +1,60 @@ +package web_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "sumeru/core/server/web" +) + +func TestSwcDirectPostRequiresLogin(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/web/swc/direct/post", nil) + web.SwcDirectPostHandlerForTest(rec, req) + if rec.Code != http.StatusFound && rec.Code != http.StatusForbidden && rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestSwcDirectUsersRequiresLogin(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/web/swc/direct/users", nil) + web.SwcDirectUsersHandlerForTest(rec, req) + if rec.Code != http.StatusFound && rec.Code != http.StatusForbidden { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestSwcDirectConversationsWithSessionNoDB(t *testing.T) { + web.SetTestSessionUserIDForTest(1) + t.Cleanup(web.ResetTestSessionUserIDForTest) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/web/swc/direct/conversations", nil) + web.SwcDirectConversationsHandlerForTest(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestSwcDirectUsersWithSessionNoDB(t *testing.T) { + web.SetTestSessionUserIDForTest(1) + t.Cleanup(web.ResetTestSessionUserIDForTest) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/web/swc/direct/users?q=admin", nil) + web.SwcDirectUsersHandlerForTest(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestSwcDirectThreadRequiresUserId(t *testing.T) { + web.SetTestSessionUserIDForTest(1) + t.Cleanup(web.ResetTestSessionUserIDForTest) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/web/swc/direct/thread", nil) + web.SwcDirectThreadHandlerForTest(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("expected error without userId") + } +} From 5c340e2968b8e94b934286f1ae760e7c976bb966 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sun, 4 Oct 2026 01:37:52 +0530 Subject: [PATCH 09/15] Add gettext PO catalogs for sys.translation. Parse and upsert PO files, sync addon i18n/*.po on module update, and extend sumeru-i18n with import-po and export-po. --- cmd/sumeru-i18n/main.go | 83 ++++++- .../module/addon_template/MODULE_STANDARD.txt | 9 +- core/module/i18n_sync.go | 62 ++++++ core/module/install.go | 20 +- core/orm/i18n_po.go | 209 ++++++++++++++++++ test/core/orm/i18n_po_test.go | 70 ++++++ 6 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 core/module/i18n_sync.go create mode 100644 core/orm/i18n_po.go create mode 100644 test/core/orm/i18n_po_test.go diff --git a/cmd/sumeru-i18n/main.go b/cmd/sumeru-i18n/main.go index 5f3a140f..5c0480e0 100644 --- a/cmd/sumeru-i18n/main.go +++ b/cmd/sumeru-i18n/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "strings" "time" "sumeru/core/orm" @@ -27,6 +28,10 @@ func main() { runImport(*configPath, args[1:]) case "export": runExport(*configPath, args[1:]) + case "import-po": + runImportPO(*configPath, args[1:]) + case "export-po": + runExportPO(*configPath, args[1:]) default: usage() os.Exit(2) @@ -34,10 +39,12 @@ func main() { } func usage() { - fmt.Fprintf(os.Stderr, `Usage: sumeru-i18n [-c sumeru.conf] import|export [flags] + fmt.Fprintf(os.Stderr, `Usage: sumeru-i18n [-c sumeru.conf] import|export|import-po|export-po [flags] import [-i translations.csv] export [-o translations.csv] + import-po -i catalog.po -m module [-l lang] + export-po -o catalog.po -m module -l lang `) } @@ -99,3 +106,77 @@ func runExport(configPath string, subArgs []string) { } fmt.Printf("Exported %d translation rows to %s\n", count, *outPath) } + +func runImportPO(configPath string, subArgs []string) { + fs := flag.NewFlagSet("import-po", flag.ExitOnError) + inPath := fs.String("i", "", "Input PO path (required)") + moduleName := fs.String("m", "", "Module name for sys.translation.module (required)") + lang := fs.String("l", "", "Language code (optional if set in PO header or filename)") + _ = fs.Parse(subArgs) + if *inPath == "" || *moduleName == "" { + fmt.Fprintln(os.Stderr, "import-po requires -i and -m") + os.Exit(2) + } + f, err := os.Open(*inPath) + if err != nil { + fmt.Fprintf(os.Stderr, "open: %v\n", err) + os.Exit(1) + } + defer f.Close() + langHint, entries, err := orm.ParsePO(f) + if err != nil { + fmt.Fprintf(os.Stderr, "po: %v\n", err) + os.Exit(1) + } + langCode := strings.TrimSpace(*lang) + if langCode == "" { + langCode = langHint + } + if langCode == "" { + langCode = orm.LangCodeFromPOFilename(*inPath) + } + ctx, db, cancel, err := cliboot.OpenConfiguredDB(configPath, 2*time.Minute) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer cancel() + defer db.Close() + imported, err := orm.ImportTranslationsPO(ctx, orm.NewDBWrapper(db), *moduleName, langCode, entries) + if err != nil { + fmt.Fprintf(os.Stderr, "import: %v\n", err) + os.Exit(1) + } + fmt.Printf("Imported %d PO strings (%s, module %s) from %s\n", imported, langCode, *moduleName, *inPath) +} + +func runExportPO(configPath string, subArgs []string) { + fs := flag.NewFlagSet("export-po", flag.ExitOnError) + outPath := fs.String("o", "", "Output PO path (required)") + moduleName := fs.String("m", "", "Module name (required)") + lang := fs.String("l", "", "Language code (required)") + _ = fs.Parse(subArgs) + if *outPath == "" || *moduleName == "" || *lang == "" { + fmt.Fprintln(os.Stderr, "export-po requires -o, -m, and -l") + os.Exit(2) + } + ctx, db, cancel, err := cliboot.OpenConfiguredDB(configPath, 30*time.Second) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer cancel() + defer db.Close() + f, err := os.Create(*outPath) + if err != nil { + fmt.Fprintf(os.Stderr, "create: %v\n", err) + os.Exit(1) + } + defer f.Close() + count, err := orm.ExportTranslationsPO(ctx, orm.NewDBWrapper(db), *moduleName, *lang, f) + if err != nil { + fmt.Fprintf(os.Stderr, "export: %v\n", err) + os.Exit(1) + } + fmt.Printf("Exported %d PO strings to %s\n", count, *outPath) +} diff --git a/core/module/addon_template/MODULE_STANDARD.txt b/core/module/addon_template/MODULE_STANDARD.txt index 1f5543b1..f60f9a26 100644 --- a/core/module/addon_template/MODULE_STANDARD.txt +++ b/core/module/addon_template/MODULE_STANDARD.txt @@ -63,6 +63,13 @@ manifest.json data load order (recommended) 6. views/*_inherit_views.xml 7. views/menus.xml (always last) +Translation catalogs (gettext PO) +--------------------------------- + Optional per-addon folder: i18n/*.po (e.g. i18n/fr_FR.po, i18n/es.po). + On module install or -u, the server imports these into sys.translation + (module = this addon's technical name). Use cmd/sumeru-i18n for manual + import-po / export-po. See addons/i18n/README.md. + File and XML id naming ---------------------- View files: {model_underscores}_{type}_views.xml e.g. core_partner_form_views.xml @@ -224,7 +231,7 @@ After adding a new addon, restart the server and install the module from /web/ap Activity and mail logging (Go) ------------------------------ -XML data files cannot run Go code, so there is no Sumeru-style automatic `message_post` from static `` rows alone. After a successful mutation in your own Go code (services, HTTP handlers, cron, and so on): use **`sumeru/addons/mail`** and **`mail.PostMessage(...)`** with **`mail.SubtypeComment`** for user-to-user chatter on a record, or **`mail.SubtypeNotification`** for record-level system notes (shown in the activity panel). **`mail.message`** is for that chatter and notifications only. For module install / uninstall / activate / update audit lines, call **`mail.LogModuleEvent(ctx, moduleName, verb, detail)`** (writes **`app.log`** via **`orm.AppendAppLog`**) or import **`sumeru/core/orm`** and **`orm.AppendAppLog`**. The stock server posts module events via **`mail.LogModuleEvent`**, record saves as notifications, and chatter posts as comments. Addons should follow the same pattern from their Go hooks rather than expecting XML to intercept ORM writes. +XML data files cannot run Go code, so there is no automatic `message_post` from static `` rows alone. After a successful mutation in your own Go code (services, HTTP handlers, cron, and so on): use **`sumeru/addons/im`** for internal user-to-user messages (`im.message`), or **`orm.AppendAppLog`** for application lifecycle / digest lines in **`app.log`**. Module install / uninstall / activate / update events are logged via **`orm.AppendAppLog`** from the module installer. Addons should follow the same pattern from Go hooks rather than expecting XML to intercept ORM writes. Server CLI ---------- diff --git a/core/module/i18n_sync.go b/core/module/i18n_sync.go new file mode 100644 index 00000000..6475489b --- /dev/null +++ b/core/module/i18n_sync.go @@ -0,0 +1,62 @@ +package module + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "sumeru/core/orm" +) + +// SyncAddonTranslationPOFiles imports gettext PO files from addonPath/i18n/*.po. +func SyncAddonTranslationPOFiles(ctx context.Context, addonPath, moduleName string) error { + if orm.DB == nil { + return nil + } + dir := filepath.Join(addonPath, "i18n") + matches, err := filepath.Glob(filepath.Join(dir, "*.po")) + if err != nil { + return err + } + if len(matches) == 0 { + return nil + } + var firstErr error + importedTotal := 0 + for _, poPath := range matches { + f, err := os.Open(poPath) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + langHint, entries, err := orm.ParsePO(f) + _ = f.Close() + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("%s: %w", filepath.Base(poPath), err) + } + continue + } + lang := strings.TrimSpace(langHint) + if lang == "" { + lang = orm.LangCodeFromPOFilename(poPath) + } + n, err := orm.ImportTranslationsPO(ctx, orm.DB, moduleName, lang, entries) + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("%s: %w", filepath.Base(poPath), err) + } + continue + } + importedTotal += n + } + if firstErr != nil { + return firstErr + } + _ = importedTotal + return nil +} diff --git a/core/module/install.go b/core/module/install.go index 0222a7cf..57c23126 100644 --- a/core/module/install.go +++ b/core/module/install.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "sumeru/addons/mail" "sumeru/core/applog" "sumeru/core/metrics" "sumeru/core/orm" @@ -116,6 +115,9 @@ func reloadModuleData(ctx context.Context, moduleName string, mode moduleReloadM if err := loadModuleXMLData(ctx, moduleName, mode, addon); err != nil { return err } + if err := SyncAddonTranslationPOFiles(ctx, addon.Path, moduleName); err != nil { + return recordSyncToDBResult(ctx, moduleName, err) + } return finalizeModuleReload(ctx, moduleName, mode) } @@ -158,12 +160,12 @@ func finalizeModuleReload(ctx context.Context, moduleName string, mode moduleRel return err } orm.InvalidateRuleCache() - mail.LogModuleEvent(ctx, moduleName, "Installed", "") + logModuleEvent(ctx, moduleName, "Installed", "") case moduleReloadUpdate: if err := setModuleStateOnly(ctx, moduleName, "installed"); err != nil { return err } - mail.LogModuleEvent(ctx, moduleName, "Updated", "module data reloaded") + logModuleEvent(ctx, moduleName, "Updated", "module data reloaded") } return nil } @@ -201,7 +203,7 @@ func UninstallModuleByName(ctx context.Context, moduleName string) error { if err := setModuleState(systemContext, moduleName, "uninstalled", true); err != nil { return err } - mail.LogModuleEvent(systemContext, moduleName, "Uninstalled", "") + logModuleEvent(systemContext, moduleName, "Uninstalled", "") return nil }) } @@ -275,9 +277,9 @@ func SetModuleActive(ctx context.Context, moduleName string, active bool) error return err } if active { - mail.LogModuleEvent(systemContext, moduleName, "Activated", "") + logModuleEvent(systemContext, moduleName, "Activated", "") } else { - mail.LogModuleEvent(systemContext, moduleName, "Deactivated", "") + logModuleEvent(systemContext, moduleName, "Deactivated", "") } return nil }) @@ -382,3 +384,9 @@ func transitiveDependencies(moduleName string) []string { } return out } + +func logModuleEvent(ctx context.Context, moduleName, verb, detail string) { + if err := orm.AppendAppLog(ctx, moduleName, verb, detail); err != nil { + applog.L(ctx).Warn("applog.log_failed", "err", err) + } +} diff --git a/core/orm/i18n_po.go b/core/orm/i18n_po.go new file mode 100644 index 00000000..7d4cc0ce --- /dev/null +++ b/core/orm/i18n_po.go @@ -0,0 +1,209 @@ +package orm + +import ( + "bufio" + "context" + "fmt" + "io" + "regexp" + "strings" +) + +// POEntry is one translated string from a gettext PO catalog. +type POEntry struct { + Context string + MsgID string + MsgStr string +} + +var poLanguageHeader = regexp.MustCompile(`(?i)Language:\s*([^\s\\]+)`) + +// ParsePO reads a gettext PO file into entries. langHint is from the header Language: line when present. +func ParsePO(r io.Reader) (langHint string, entries []POEntry, err error) { + sc := bufio.NewScanner(r) + var cur POEntry + var collecting string + var target *string + + flush := func() { + if cur.MsgID == "" && cur.MsgStr == "" { + return + } + if cur.MsgID == "" { + if m := poLanguageHeader.FindStringSubmatch(cur.MsgStr); len(m) == 2 { + langHint = strings.TrimSpace(m[1]) + } + cur = POEntry{} + return + } + entries = append(entries, cur) + cur = POEntry{} + } + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + if collecting != "" { + flush() + collecting = "" + target = nil + } + continue + } + if strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "msgctxt ") { + cur.Context = unquotePO(line[len("msgctxt "):]) + continue + } + if strings.HasPrefix(line, "msgid ") { + if collecting != "" && cur.MsgID != "" { + flush() + } + collecting = "msgid" + cur.MsgID = unquotePO(line[len("msgid "):]) + target = &cur.MsgID + continue + } + if strings.HasPrefix(line, "msgstr ") { + collecting = "msgstr" + cur.MsgStr = unquotePO(line[len("msgstr "):]) + target = &cur.MsgStr + continue + } + if strings.HasPrefix(line, `"`) && target != nil { + *target += unquotePO(line) + } + } + if err := sc.Err(); err != nil { + return "", nil, err + } + if collecting != "" { + flush() + } + return langHint, entries, nil +} + +func unquotePO(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + body := s[1 : len(s)-1] + return strings.ReplaceAll(body, `\"`, `"`) + } + return s +} + +// NormalizeLangCode maps PO language tags to core.lang-style codes (e.g. fr → fr_FR). +func NormalizeLangCode(code string) string { + code = strings.TrimSpace(code) + code = strings.ReplaceAll(code, "-", "_") + if code == "" { + return "" + } + if strings.Contains(code, "_") { + return code + } + switch code { + case "en": + return "en_US" + default: + return code + "_" + strings.ToUpper(code) + } +} + +// LangCodeFromPOFilename extracts a language code from names like fr_FR.po or es.po. +func LangCodeFromPOFilename(name string) string { + base := name + if i := strings.LastIndex(base, "/"); i >= 0 { + base = base[i+1:] + } + base = strings.TrimSuffix(base, ".po") + return NormalizeLangCode(base) +} + +// ImportTranslationsPO upserts PO entries for moduleName and lang. +func ImportTranslationsPO(ctx context.Context, db DBWrapper, moduleName, lang string, entries []POEntry) (int, error) { + if db == nil { + return 0, fmt.Errorf("db required") + } + lang = NormalizeLangCode(lang) + if lang == "" { + return 0, fmt.Errorf("language code required") + } + mod := strings.TrimSpace(moduleName) + imported := 0 + for _, e := range entries { + src := strings.TrimSpace(e.MsgID) + if src == "" { + continue + } + if err := upsertTranslation(ctx, db, lang, src, e.MsgStr, mod); err != nil { + return imported, err + } + imported++ + } + return imported, nil +} + +// ExportTranslationsPO writes PO catalog for moduleName and lang. +func ExportTranslationsPO(ctx context.Context, db DBWrapper, moduleName, lang string, w io.Writer) (int, error) { + lang = NormalizeLangCode(lang) + mod := strings.TrimSpace(moduleName) + tbl := MustQuotedTableName("sys.translation") + rows, err := db.QueryContext(ctx, + `SELECT src, value FROM `+tbl+` WHERE lang = $1 AND module = $2 ORDER BY src`, + lang, mod) + if err != nil { + return 0, err + } + defer rows.Close() + + if _, err := fmt.Fprintf(w, "# Sumeru translation catalog\nmsgid \"\"\nmsgstr \"\"\n\"Language: %s\\n\"\n\n", lang); err != nil { + return 0, err + } + + count := 0 + for rows.Next() { + var src, value string + if err := rows.Scan(&src, &value); err != nil { + return count, err + } + if err := writePOEntry(w, "msgid", src); err != nil { + return count, err + } + if err := writePOEntry(w, "msgstr", value); err != nil { + return count, err + } + if _, err := fmt.Fprintln(w); err != nil { + return count, err + } + count++ + } + return count, rows.Err() +} + +func writePOEntry(w io.Writer, keyword, s string) error { + esc := func(x string) string { + x = strings.ReplaceAll(x, `\`, `\\`) + return strings.ReplaceAll(x, `"`, `\"`) + } + if !strings.Contains(s, "\n") { + _, err := fmt.Fprintf(w, `%s "%s"`+"\n", keyword, esc(s)) + return err + } + parts := strings.Split(s, "\n") + if _, err := fmt.Fprintf(w, `%s ""`+"\n", keyword); err != nil { + return err + } + for i, p := range parts { + line := `"` + esc(p) + `"` + if i < len(parts)-1 { + line += `\n` + } + if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { + return err + } + } + return nil +} diff --git a/test/core/orm/i18n_po_test.go b/test/core/orm/i18n_po_test.go new file mode 100644 index 00000000..1d7ae61d --- /dev/null +++ b/test/core/orm/i18n_po_test.go @@ -0,0 +1,70 @@ +package orm_test + +import ( + "strings" + "testing" + + "sumeru/core/orm" +) + +func TestParsePO_simple(t *testing.T) { + raw := `# comment +msgid "Hello" +msgstr "Bonjour" + +msgctxt "base" +msgid "Settings" +msgstr "Paramètres" +` + lang, entries, err := orm.ParsePO(strings.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if lang != "" { + t.Fatalf("lang hint = %q", lang) + } + if len(entries) != 2 { + t.Fatalf("entries = %d", len(entries)) + } + if entries[0].MsgID != "Hello" || entries[0].MsgStr != "Bonjour" { + t.Fatalf("first entry: %+v", entries[0]) + } + if entries[1].Context != "base" || entries[1].MsgID != "Settings" { + t.Fatalf("second entry: %+v", entries[1]) + } +} + +func TestParsePO_headerLanguage(t *testing.T) { + raw := `msgid "" +msgstr "" +"Language: fr_FR\n" + +msgid "Hi" +msgstr "Salut" +` + lang, entries, err := orm.ParsePO(strings.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if lang != "fr_FR" { + t.Fatalf("lang = %q", lang) + } + if len(entries) != 1 || entries[0].MsgStr != "Salut" { + t.Fatalf("entries: %+v", entries) + } +} + +func TestNormalizeLangCode(t *testing.T) { + if orm.NormalizeLangCode("fr") != "fr_FR" { + t.Fatal("fr") + } + if orm.NormalizeLangCode("en_US") != "en_US" { + t.Fatal("en_US") + } +} + +func TestLangCodeFromPOFilename(t *testing.T) { + if got := orm.LangCodeFromPOFilename("/tmp/es.po"); got != "es_ES" { + t.Fatalf("got %q", got) + } +} From 7d809730d4c9cc88baa299041da83c432154dc71 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sun, 4 Oct 2026 01:38:29 +0530 Subject: [PATCH 10/15] Consolidate translation helpers and update tests for geo/i18n menus. Single upsert path for CSV/PO imports, merge runtime translate helpers, and fix settings sidebar expectations after the geo split. --- core/engine/render/branding.go | 49 +-------- core/engine/render/render_types.go | 5 +- core/engine/swcmeta/types.go | 4 +- core/orm/i18n_csv.go | 62 ++++++----- core/orm/i18n_runtime.go | 35 ------ core/orm/i18n_translate.go | 19 ++++ core/server/web/record_error_flash.go | 50 --------- test/core/coverage/exports_test.go | 34 ++++++ .../core/engine/render/menus_settings_test.go | 20 ++-- test/core/engine/swcmeta/types_sync_test.go | 1 + test/core/importgen/init_gen_test.go | 10 +- test/core/orm/pure_helpers_test.go | 5 +- .../server/web/bulk_preview_handler_test.go | 12 --- test/core/server/web/helpers_coverage_test.go | 6 -- .../static/convention_all_addons_test.go | 2 +- test/module/unit/cli_install_all_test.go | 8 +- test/module/unit/dependency_closure_test.go | 6 +- test/module/unit/geo_menu_sync_test.go | 101 +++++++++++------- 18 files changed, 182 insertions(+), 247 deletions(-) delete mode 100644 core/orm/i18n_runtime.go diff --git a/core/engine/render/branding.go b/core/engine/render/branding.go index a04b98e6..04d19f23 100644 --- a/core/engine/render/branding.go +++ b/core/engine/render/branding.go @@ -2,12 +2,10 @@ package render import ( "context" - "fmt" "html/template" "strings" - "time" - "sumeru/addons/mail" + "sumeru/addons/im" "sumeru/core/orm" "sumeru/core/server/config" ) @@ -112,7 +110,7 @@ func EnrichShellPageData(ctx context.Context, d *PageData) { } d.ShellUserInitials = UserInitialsFromName(d.ShellUser) - d.ActivityEnabled = mail.CompanyChatterEnabled(ctx) && mail.CompanyActivityPanelEnabled(ctx) + d.ActivityEnabled = im.IMEnabled(ctx) if len(d.ExtraScriptURLs) == 0 { d.ExtraScriptURLs = ExtraScriptURLs } @@ -122,29 +120,6 @@ func EnrichShellPageData(ctx context.Context, d *PageData) { if d.SuppressActivityDock { d.ActivityEnabled = false } - if !d.ActivityEnabled { - d.ActivityLogItems = nil - d.appendShellHooks(ctx) - return - } - rows, err := mail.QueryActivityLog(ctx, 40, d.ActivityContextModel, d.ActivityContextRecordID) - if err != nil { - d.ActivityLogItems = nil - d.appendShellHooks(ctx) - return - } - for _, r := range rows { - author := strings.TrimSpace(r.Author) - if author == "" { - author = "System" - } - meta := author - if !r.CreateDate.IsZero() { - meta = fmt.Sprintf("%s · %s", author, shortRelTime(r.CreateDate)) - } - d.ActivityLogItems = append(d.ActivityLogItems, ActivityItem{Meta: meta, Body: strings.TrimSpace(r.Body)}) - } - d.appendShellHooks(ctx) } @@ -198,23 +173,3 @@ func shellCompanyNameFromOptions(opts []ShellCompanyOption, preferID int) string return "" } -func shortRelTime(t time.Time) string { - t = t.UTC() - now := time.Now().UTC() - if t.After(now) { - t = now - } - d := now.Sub(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) - case d < 48*time.Hour: - return "yesterday" - default: - return t.Local().Format("Jan 02") - } -} diff --git a/core/engine/render/render_types.go b/core/engine/render/render_types.go index 9f88467c..c6e942b8 100644 --- a/core/engine/render/render_types.go +++ b/core/engine/render/render_types.go @@ -106,13 +106,10 @@ type PageData struct { ToastMessages []FlashMessage ToastMessagesJSON template.JS - // Right activity panel: Log tab (audit); Messages tab HTML set in RenderView when chatter applies. + // Right activity panel (Messages + Log tabs filled by SWC when SWCEnabled). ActivityEnabled bool - ActivityLogItems []ActivityItem ActivityContextModel string ActivityContextRecordID int64 - ActivityPanelChatter bool - ActivityChatterHTML template.HTML // SWC bootstrap JSON injected as window.__SWC_BOOTSTRAP__ SWCBootstrapJSON template.JS diff --git a/core/engine/swcmeta/types.go b/core/engine/swcmeta/types.go index c5d4b601..0560e8fc 100644 --- a/core/engine/swcmeta/types.go +++ b/core/engine/swcmeta/types.go @@ -27,7 +27,9 @@ type WorkspacePayload struct { FormBaseQuery string `json:"formBaseQuery,omitempty"` Defaults map[string]interface{} `json:"defaults,omitempty"` IframeURL string `json:"iframeUrl,omitempty"` - UserSecurity *UserSecurityPayload `json:"userSecurity,omitempty"` + UserSecurity *UserSecurityPayload `json:"userSecurity,omitempty"` + RecordMessagesEligible bool `json:"recordMessagesEligible,omitempty"` + RecordLogEligible bool `json:"recordLogEligible,omitempty"` } type ViewTab struct { diff --git a/core/orm/i18n_csv.go b/core/orm/i18n_csv.go index b1a075f5..40a78a37 100644 --- a/core/orm/i18n_csv.go +++ b/core/orm/i18n_csv.go @@ -13,11 +13,6 @@ import ( // TranslationCSVHeader is the column order for sys.translation import/export. var TranslationCSVHeader = []string{"lang", "src", "value", "module"} -// TranslationTableName returns the quoted table name for sys.translation. -func TranslationTableName() (string, error) { - return ModelToTableName("sys.translation") -} - // ParseTranslationCSV reads a translation CSV and validates required columns. func ParseTranslationCSV(r io.Reader) (header []string, rows [][]string, err error) { reader := csv.NewReader(r) @@ -29,10 +24,7 @@ func ParseTranslationCSV(r io.Reader) (header []string, rows [][]string, err err return all[0], nil, nil } header = all[0] - col := map[string]int{} - for i, h := range header { - col[strings.ToLower(strings.TrimSpace(h))] = i - } + col := csvColumnIndex(header) for _, req := range TranslationCSVHeader { if _, ok := col[req]; !ok { return header, nil, fmt.Errorf("missing column %q", req) @@ -43,31 +35,22 @@ func ParseTranslationCSV(r io.Reader) (header []string, rows [][]string, err err // ImportTranslationsCSV inserts or updates rows from parsed CSV data. func ImportTranslationsCSV(ctx context.Context, db *sql.DB, header []string, rows [][]string) (int, error) { - tableName, err := TranslationTableName() - if err != nil { - return 0, err - } - col := map[string]int{} - for i, h := range header { - col[strings.ToLower(strings.TrimSpace(h))] = i + if db == nil { + return 0, fmt.Errorf("db required") } + wrap := NewDBWrapper(db) + col := csvColumnIndex(header) imported := 0 for _, row := range rows { if len(row) < len(header) { continue } - lang := strings.TrimSpace(row[col["lang"]]) + lang := NormalizeLangCode(strings.TrimSpace(row[col["lang"]])) src := strings.TrimSpace(row[col["src"]]) - val := row[col["value"]] - mod := strings.TrimSpace(row[col["module"]]) if lang == "" || src == "" { continue } - _, err := db.ExecContext(ctx, ` - INSERT INTO "`+tableName+`" (lang, src, value, module) - VALUES ($1, $2, $3, $4) - ON CONFLICT (lang, src, module) DO UPDATE SET value = EXCLUDED.value`, lang, src, val, mod) - if err != nil { + if err := upsertTranslation(ctx, wrap, lang, src, row[col["value"]], strings.TrimSpace(row[col["module"]])); err != nil { return imported, err } imported++ @@ -77,12 +60,9 @@ func ImportTranslationsCSV(ctx context.Context, db *sql.DB, header []string, row // ExportTranslationsCSV writes all sys.translation rows to outPath. func ExportTranslationsCSV(ctx context.Context, db *sql.DB, outPath string) (int, error) { - tableName, err := TranslationTableName() - if err != nil { - return 0, err - } + tbl := MustQuotedTableName("sys.translation") rows, err := db.QueryContext(ctx, - `SELECT lang, src, value, module FROM "`+tableName+`" ORDER BY lang, module, src`) + `SELECT lang, src, value, module FROM `+tbl+` ORDER BY lang, module, src`) if err != nil { return 0, err } @@ -115,3 +95,27 @@ func ExportTranslationsCSV(ctx context.Context, db *sql.DB, outPath string) (int w.Flush() return count, w.Error() } + +func csvColumnIndex(header []string) map[string]int { + col := map[string]int{} + for i, h := range header { + col[strings.ToLower(strings.TrimSpace(h))] = i + } + return col +} + +func upsertTranslation(ctx context.Context, db DBWrapper, lang, src, value, module string) error { + lang = strings.TrimSpace(lang) + src = strings.TrimSpace(src) + module = strings.TrimSpace(module) + if lang == "" || src == "" { + return fmt.Errorf("lang and src required") + } + tbl := MustQuotedTableName("sys.translation") + _, err := db.ExecContext(ctx, + `INSERT INTO `+tbl+` (lang, src, value, module) + VALUES ($1, $2, $3, $4) + ON CONFLICT (lang, src, module) DO UPDATE SET value = EXCLUDED.value`, + lang, src, value, module) + return err +} diff --git a/core/orm/i18n_runtime.go b/core/orm/i18n_runtime.go deleted file mode 100644 index 4a572edc..00000000 --- a/core/orm/i18n_runtime.go +++ /dev/null @@ -1,35 +0,0 @@ -package orm - -import ( - "context" - "strings" -) - -// TranslateFieldLabel resolves a field label for the user's language from sys.translation. -func TranslateFieldLabel(ctx context.Context, modelName, fieldName, defaultLabel string) string { - if DB == nil { - return defaultLabel - } - lang := UserLang(ctx) - if lang == "" || lang == "en_US" { - return defaultLabel - } - key := modelName + "," + fieldName - rows, err := Search(ctx, "sys.translation", [][]interface{}{ - {"lang", "=", lang}, - {"src", "=", key}, - }) - if err != nil || len(rows) == 0 { - return defaultLabel - } - if v := strings.TrimSpace(AsString(rows[0]["value"])); v != "" { - return v - } - return defaultLabel -} - -// UserLang returns the active language code for ctx, defaulting to en_US. -func UserLang(ctx context.Context) string { - _ = ctx - return "en_US" -} diff --git a/core/orm/i18n_translate.go b/core/orm/i18n_translate.go index 29d09f29..c94dcb73 100644 --- a/core/orm/i18n_translate.go +++ b/core/orm/i18n_translate.go @@ -26,3 +26,22 @@ func Translate(ctx context.Context, lang, src string) string { } return val.String } + +// TranslateFieldLabel resolves a field label for the user's language from sys.translation. +func TranslateFieldLabel(ctx context.Context, modelName, fieldName, defaultLabel string) string { + lang := UserLang(ctx) + if lang == "" || lang == "en_US" { + return defaultLabel + } + key := modelName + "," + fieldName + if v := Translate(ctx, lang, key); v != key { + return v + } + return defaultLabel +} + +// UserLang returns the active language code for ctx, defaulting to en_US. +func UserLang(ctx context.Context) string { + _ = ctx + return "en_US" +} diff --git a/core/server/web/record_error_flash.go b/core/server/web/record_error_flash.go index 0760c56d..6ef65e88 100644 --- a/core/server/web/record_error_flash.go +++ b/core/server/web/record_error_flash.go @@ -3,7 +3,6 @@ package web import ( "errors" "fmt" - "net/http" "net/url" "strings" @@ -36,8 +35,6 @@ func userFacingRecordError(operation, model string, err error) (title, body, det title = "Save failed" case "record_delete": title = "Delete failed" - case "record_chatter": - title = "Message not posted" case "object_action": title = "Action failed" default: @@ -125,53 +122,6 @@ func fieldLabel(modelName, fieldName string) string { return fieldName } -func redirectRecordError(w http.ResponseWriter, r *http.Request, nextURL, operation, model string, err error) { - ctx := r.Context() - title, body, details, fieldErrors := userFacingRecordError(operation, model, err) - WebLogEvent(ctx, WebLogInput{ - Route: operationRoute(operation), Message: body, - Code: orm.ClassifyLogCode(err), - Operation: operation, Status: logStatusFailure, Err: err, - ContextFields: map[string]interface{}{"model": model}, - }) - SetRecordErrorFlash(w, PageFlash{ - Kind: "error", - Title: title, - Body: body, - Details: details, - FieldErrors: fieldErrors, - }) - redirectURL := appendFieldErrorsToURL(SafeWebNext(nextURL, homeRoute), fieldErrors) - http.Redirect(w, r, redirectURL, http.StatusSeeOther) -} - -func appendFieldErrorsToURL(rawURL string, fieldErrors []string) string { - if len(fieldErrors) == 0 { - return rawURL - } - parsed, err := url.Parse(rawURL) - if err != nil { - return rawURL - } - query := parsed.Query() - query.Set(fieldErrorsParam, strings.Join(fieldErrors, ",")) - parsed.RawQuery = query.Encode() - return parsed.String() -} - -func operationRoute(operation string) string { - switch operation { - case "record_save", "record_delete": - return workspaceRoute - case "record_chatter": - return chatterPostRoute - case "object_action": - return apiRPCRoute - default: - return webLogUnknownRoute - } -} - func ensureFormEditRedirectURL(rawNext string, clearRecordID bool) string { nextPath := SafeWebNext(rawNext, homeRoute) parsed, err := url.Parse(nextPath) diff --git a/test/core/coverage/exports_test.go b/test/core/coverage/exports_test.go index a5706ef0..cb209e70 100644 --- a/test/core/coverage/exports_test.go +++ b/test/core/coverage/exports_test.go @@ -11,6 +11,8 @@ import ( "time" "sumeru/addons/automation" + "sumeru/addons/audit" + "sumeru/addons/im" "sumeru/core/applog" "sumeru/core/engine/parser" "sumeru/core/engine/render" @@ -395,4 +397,36 @@ func TestForTestExports_web(t *testing.T) { if !web.ContentInlineAllowedForTest("image/png") { t.Fatal("ContentInlineAllowedForTest image") } + payload := &swcmeta.WorkspacePayload{} + web.ApplyActivityRecordFlagsForTest(context.Background(), payload, "list", 0, "core.user") + if payload.RecordMessagesEligible || payload.RecordLogEligible { + t.Fatal("record id 0 should not enable activity flags") + } + web.ApplyActivityRecordFlagsForTest(context.Background(), payload, "form", 0, "core.user") + if payload.RecordMessagesEligible || payload.RecordLogEligible { + t.Fatal("unsaved form should not enable activity flags") + } + if d, err := audit.RetentionDaysFromRecordForTest(map[string]interface{}{"retention_preset": "7_days"}); err != nil || d != 7 { + t.Fatalf("retention 7d: %d %v", d, err) + } + opts := audit.RetentionOptionsFromPolicyForTest(map[string]interface{}{"hot_retention_days": 60}, true) + if opts.RetentionDays != 60 || !opts.DryRun { + t.Fatalf("retention opts: %+v", opts) + } + if err := audit.RunAuditRetentionCronForTest(context.Background()); err != nil { + t.Fatal(err) + } + raw, err := audit.EncodeAuditJSONLForTest([]audit.AuditExportRowForTest{{ID: 1, Action: "read", Model: "x"}}) + if err != nil { + t.Fatal(err) + } + if _, err := audit.DecodeAuditJSONLForTest(raw); err != nil { + t.Fatal(err) + } + if name := im.UserDisplayNameForTest(context.Background(), 1); name == "" { + t.Fatal("UserDisplayNameForTest") + } + directRec := httptest.NewRecorder() + directReq := httptest.NewRequest(http.MethodGet, "/web/swc/direct/conversations", nil) + web.SwcDirectConversationsHandlerForTest(directRec, directReq) } diff --git a/test/core/engine/render/menus_settings_test.go b/test/core/engine/render/menus_settings_test.go index 1d4b0e03..43b3f9c9 100644 --- a/test/core/engine/render/menus_settings_test.go +++ b/test/core/engine/render/menus_settings_test.go @@ -7,10 +7,10 @@ import ( ) -func TestBuildSidebarMenus_localizationSection(t *testing.T) { +func TestBuildSidebarMenus_regionalDataSection(t *testing.T) { allMenus := []parser.MenuItem{ {ID: "100", Name: "Settings", Sequence: 5}, - {ID: "160", Name: "Localization", ParentID: "100", Sequence: 60, AccessGroups: "base.group_system"}, + {ID: "160", Name: "Regional data", ParentID: "100", Sequence: 60, AccessGroups: "base.group_system"}, {ID: "161", Name: "Countries", ParentID: "160", Sequence: 10, Action: "/web?action=10&menu_id=161", AccessGroups: "base.group_system"}, {ID: "162", Name: "States", ParentID: "160", Sequence: 20, Action: "/web?action=11&menu_id=162", AccessGroups: "base.group_system"}, {ID: "163", Name: "Cities", ParentID: "160", Sequence: 30, Action: "/web?action=12&menu_id=163", AccessGroups: "base.group_system"}, @@ -19,21 +19,21 @@ func TestBuildSidebarMenus_localizationSection(t *testing.T) { return mi.AccessGroups == "base.group_system" } sections := render.BuildSidebarMenus(allMenus, "100", menuAllowed) - var localization *render.SidebarMenu + var regional *render.SidebarMenu for i := range sections { - if sections[i].Name == "Localization" { - localization = §ions[i] + if sections[i].Name == "Regional data" { + regional = §ions[i] break } } - if localization == nil { - t.Fatal("Localization section not found in Settings sidebar") + if regional == nil { + t.Fatal("Regional data section not found in Settings sidebar") } - if len(localization.SubMenus) != 3 { - t.Fatalf("Localization submenus = %d; want 3", len(localization.SubMenus)) + if len(regional.SubMenus) != 3 { + t.Fatalf("Regional data submenus = %d; want 3", len(regional.SubMenus)) } names := map[string]bool{} - for _, sm := range localization.SubMenus { + for _, sm := range regional.SubMenus { names[sm.Name] = true if sm.Action == "" { t.Fatalf("submenu %q has empty action URL", sm.Name) diff --git a/test/core/engine/swcmeta/types_sync_test.go b/test/core/engine/swcmeta/types_sync_test.go index 06c1a008..72328a57 100644 --- a/test/core/engine/swcmeta/types_sync_test.go +++ b/test/core/engine/swcmeta/types_sync_test.go @@ -13,6 +13,7 @@ func TestWorkspacePayloadJSONFieldsContract(t *testing.T) { "arch", "record", "records", "viewTabs", "breadcrumbs", "listSearch", "listSearchUrl", "listTotal", "listSort", "listOffset", "listFilter", "listDomain", "listGroupBy", "listSections", "favorites", "formBaseQuery", "defaults", "iframeUrl", "userSecurity", + "recordMessagesEligible", "recordLogEligible", } typ := swcmeta.WorkspacePayloadTypeForTest() got := make([]string, 0, typ.NumField()) diff --git a/test/core/importgen/init_gen_test.go b/test/core/importgen/init_gen_test.go index 65803ea4..d97fee95 100644 --- a/test/core/importgen/init_gen_test.go +++ b/test/core/importgen/init_gen_test.go @@ -77,9 +77,9 @@ func TestExpectedInitDependsImportPathsWorkspace(t *testing.T) { Manifest: module.Manifest{Name: "contacts", Depends: []string{"base"}}, Path: filepath.Join(root, "addons", "contacts"), }, - "mail": { - Manifest: module.Manifest{Name: "mail", Depends: []string{"base"}}, - Path: filepath.Join(root, "addons", "mail"), + "im": { + Manifest: module.Manifest{Name: "im", Depends: []string{"base"}}, + Path: filepath.Join(root, "addons", "im"), }, "hr": { Manifest: module.Manifest{Name: "hr", Depends: []string{"base", "contacts"}}, @@ -88,7 +88,7 @@ func TestExpectedInitDependsImportPathsWorkspace(t *testing.T) { "engagement_cookbook": { Manifest: module.Manifest{ Name: "engagement_cookbook", - Depends: []string{"base", "contacts", "hr", "mail"}, + Depends: []string{"base", "contacts", "hr", "im"}, }, Path: cookbookPath, }, @@ -108,7 +108,7 @@ func TestExpectedInitDependsImportPathsWorkspace(t *testing.T) { "sumeru/addons/base", "sumeru/addons/contacts", "sumeru_addons/hr", - "sumeru/addons/mail", + "sumeru/addons/im", } { if _, ok := set[want]; !ok { t.Fatalf("missing import %q in %v", want, paths) diff --git a/test/core/orm/pure_helpers_test.go b/test/core/orm/pure_helpers_test.go index 5da56e2c..92f4e789 100644 --- a/test/core/orm/pure_helpers_test.go +++ b/test/core/orm/pure_helpers_test.go @@ -227,9 +227,8 @@ func TestTranslationCSVParse(t *testing.T) { if _, _, err := orm.ParseTranslationCSV(strings.NewReader("lang,src\nen,Hi")); err == nil { t.Fatal("expected missing column") } - table, err := orm.TranslationTableName() - if err != nil || table != "sys_translation" { - t.Fatalf("table: %q err=%v", table, err) + if orm.MustQuotedTableName("sys.translation") != `"sys_translation"` { + t.Fatal("sys.translation table name") } } diff --git a/test/core/server/web/bulk_preview_handler_test.go b/test/core/server/web/bulk_preview_handler_test.go index 239b90a1..cb2ed90f 100644 --- a/test/core/server/web/bulk_preview_handler_test.go +++ b/test/core/server/web/bulk_preview_handler_test.go @@ -33,18 +33,6 @@ func TestSwcImportHandlerRequiresBatch(t *testing.T) { } } -func TestChatterUploadRequiresMultipart(t *testing.T) { - web.ResetTestSessionUserIDForTest() - t.Cleanup(web.ResetTestSessionUserIDForTest) - web.SetTestSessionUserIDForTest(1) - req := httptest.NewRequest(http.MethodPost, "/web/chatter/upload", nil) - rec := httptest.NewRecorder() - web.ChatterUploadHandlerForTest(rec, req) - if rec.Code == http.StatusOK { - t.Fatal("expected failure") - } -} - func TestImportWizardPageRequiresBatch(t *testing.T) { web.ResetTestSessionUserIDForTest() t.Cleanup(web.ResetTestSessionUserIDForTest) diff --git a/test/core/server/web/helpers_coverage_test.go b/test/core/server/web/helpers_coverage_test.go index 14df8f8d..8758a13f 100644 --- a/test/core/server/web/helpers_coverage_test.go +++ b/test/core/server/web/helpers_coverage_test.go @@ -93,12 +93,6 @@ func TestWebHelperExportsCoverage(t *testing.T) { if got := web.FormBaseQueryValues(1, "2", "list", "3"); got == "" { t.Fatal("form base query") } - if got := web.ChatterBodyTooLong(strings.Repeat("x", int(web.TestMaxChatterBodyRunes)+1)); !got { - t.Fatal("chatter too long") - } - if _, err := web.ParseChatterRecordID("12"); err != nil { - t.Fatal(err) - } if got := web.CoerceCSVValue("42"); got != int64(42) && got != 42 { t.Fatalf("csv value: %v", got) } diff --git a/test/module/static/convention_all_addons_test.go b/test/module/static/convention_all_addons_test.go index cf6001b3..2fdebb23 100644 --- a/test/module/static/convention_all_addons_test.go +++ b/test/module/static/convention_all_addons_test.go @@ -38,7 +38,7 @@ func TestResolveInstallClosureCoreAddons(t *testing.T) { if err != nil { t.Fatal(err) } - for _, name := range []string{"base", "contacts", "mail"} { + for _, name := range []string{"base", "contacts", "im"} { if _, ok := discovered[name]; !ok { continue } diff --git a/test/module/unit/cli_install_all_test.go b/test/module/unit/cli_install_all_test.go index 2a497d5f..db745277 100644 --- a/test/module/unit/cli_install_all_test.go +++ b/test/module/unit/cli_install_all_test.go @@ -10,18 +10,18 @@ import ( func TestExpandInstallModuleNamesExplicitOrder(t *testing.T) { module.DiscoveredAddons = map[string]*module.Addon{ "base": {Manifest: module.Manifest{Name: "base", Depends: []string{}}}, - "mail": {Manifest: module.Manifest{Name: "mail", Depends: []string{"base"}}}, - "crm": {Manifest: module.Manifest{Name: "crm", Depends: []string{"base", "mail"}}}, + "im": {Manifest: module.Manifest{Name: "im", Depends: []string{"base"}}}, + "crm": {Manifest: module.Manifest{Name: "crm", Depends: []string{"base", "im"}}}, } ctx := context.Background() - names, err := module.ExpandInstallModuleNamesForTest(ctx, []string{"crm", "mail"}) + names, err := module.ExpandInstallModuleNamesForTest(ctx, []string{"crm", "im"}) if err != nil { t.Fatal(err) } if len(names) != 2 { t.Fatalf("expected 2 modules, got %v", names) } - if names[0] != "mail" || names[1] != "crm" { + if names[0] != "im" || names[1] != "crm" { t.Fatalf("unexpected order: %v", names) } } diff --git a/test/module/unit/dependency_closure_test.go b/test/module/unit/dependency_closure_test.go index 11705f0a..6464be54 100644 --- a/test/module/unit/dependency_closure_test.go +++ b/test/module/unit/dependency_closure_test.go @@ -24,12 +24,12 @@ func TestResolveInstallClosureEngagementCookbook(t *testing.T) { discovered := map[string]*module.Addon{ "base": {Manifest: module.Manifest{Name: "base", Depends: nil}}, "contacts": {Manifest: module.Manifest{Name: "contacts", Depends: []string{"base"}}}, - "mail": {Manifest: module.Manifest{Name: "mail", Depends: []string{"base"}}}, + "im": {Manifest: module.Manifest{Name: "im", Depends: []string{"base"}}}, "hr": {Manifest: module.Manifest{Name: "hr", Depends: []string{"base", "contacts"}}}, "engagement_cookbook": { Manifest: module.Manifest{ Name: "engagement_cookbook", - Depends: []string{"base", "contacts", "hr", "mail"}, + Depends: []string{"base", "contacts", "hr", "im"}, }, }, } @@ -37,7 +37,7 @@ func TestResolveInstallClosureEngagementCookbook(t *testing.T) { if err != nil { t.Fatal(err) } - want := []string{"base", "contacts", "hr", "mail", "engagement_cookbook"} + want := []string{"base", "contacts", "hr", "im", "engagement_cookbook"} if len(closure) != len(want) { t.Fatalf("closure len = %d, want %d: %v", len(closure), len(want), closure) } diff --git a/test/module/unit/geo_menu_sync_test.go b/test/module/unit/geo_menu_sync_test.go index c844d51e..40691762 100644 --- a/test/module/unit/geo_menu_sync_test.go +++ b/test/module/unit/geo_menu_sync_test.go @@ -11,19 +11,27 @@ import ( "sumeru/core/module" ) -func baseAddonDir(t *testing.T) string { +func addonDir(t *testing.T, name string) string { t.Helper() - dir := filepath.Join("..", "..", "..", "addons", "base") + dir := filepath.Join("..", "..", "..", "addons", name) if _, err := os.Stat(dir); err != nil { - t.Skip("base addon not found at ", dir) + t.Skip(name, " addon not found at ", dir) } return dir } -func collectBaseManifestMenus(t *testing.T) []parser.MenuItem { +func menuByID(menus []parser.MenuItem, id string) (parser.MenuItem, bool) { + for _, m := range menus { + if m.ID == id { + return m, true + } + } + return parser.MenuItem{}, false +} + +func collectManifestMenus(t *testing.T, addonDir string) []parser.MenuItem { t.Helper() - baseDir := baseAddonDir(t) - manifestPath := filepath.Join(baseDir, "manifest.json") + manifestPath := filepath.Join(addonDir, "manifest.json") raw, err := os.ReadFile(manifestPath) if err != nil { t.Fatalf("read manifest: %v", err) @@ -39,7 +47,7 @@ func collectBaseManifestMenus(t *testing.T) []parser.MenuItem { if !strings.HasSuffix(strings.ToLower(rel), ".xml") { continue } - items, err := module.CollectMenuItemsFromManifestFile(filepath.Join(baseDir, rel)) + items, err := module.CollectMenuItemsFromManifestFile(filepath.Join(addonDir, rel)) if err != nil { t.Fatalf("collect menus from %s: %v", rel, err) } @@ -48,58 +56,77 @@ func collectBaseManifestMenus(t *testing.T) []parser.MenuItem { return out } -func menuByID(menus []parser.MenuItem, id string) (parser.MenuItem, bool) { - for _, m := range menus { - if m.ID == id { - return m, true - } - } - return parser.MenuItem{}, false -} - -func TestBaseDeferredMenusIncludeGeoSection(t *testing.T) { - menus := collectBaseManifestMenus(t) +func TestGeoDeferredMenusIncludeGeoSection(t *testing.T) { + menus := collectManifestMenus(t, addonDir(t, "geo")) geoSection, ok := menuByID(menus, "menu_geo_section") if !ok { - t.Fatal("menu_geo_section missing from collected base manifest menus") - } - if geoSection.ParentID != "menu_settings_root" { - t.Fatalf("menu_geo_section parent = %q; want menu_settings_root", geoSection.ParentID) + t.Fatal("menu_geo_section missing from geo manifest menus") } - if _, ok := menuByID(menus, "menu_settings_root"); !ok { - t.Fatal("menu_settings_root missing — geo section parent would be unresolved") + if geoSection.ParentID != "base.menu_settings_root" { + t.Fatalf("menu_geo_section parent = %q; want base.menu_settings_root", geoSection.ParentID) } for _, childID := range []string{"menu_core_country", "menu_core_country_state", "menu_core_city"} { child, ok := menuByID(menus, childID) if !ok { - t.Fatalf("%s missing from collected menus", childID) + t.Fatalf("%s missing", childID) } if child.ParentID != "menu_geo_section" { t.Fatalf("%s parent = %q; want menu_geo_section", childID, child.ParentID) } - if !strings.Contains(child.AccessGroups, "base.group_system") { - t.Fatalf("%s access_groups = %q; want base.group_system", childID, child.AccessGroups) + } +} + +func TestI18nDeferredMenusIncludeI18nSection(t *testing.T) { + menus := collectManifestMenus(t, addonDir(t, "i18n")) + section, ok := menuByID(menus, "menu_i18n_section") + if !ok { + t.Fatal("menu_i18n_section missing") + } + if section.Name != "Internationalization" { + t.Fatalf("section name = %q", section.Name) + } + for _, childID := range []string{"menu_core_lang", "menu_sys_translation"} { + if _, ok := menuByID(menus, childID); !ok { + t.Fatalf("%s missing", childID) } } } -func TestBaseManifestMenusLoadLast(t *testing.T) { - baseDir := baseAddonDir(t) - raw, err := os.ReadFile(filepath.Join(baseDir, "manifest.json")) - if err != nil { - t.Fatal(err) +func TestGeoManifestMenusLoadLast(t *testing.T) { + dir := addonDir(t, "geo") + raw, _ := os.ReadFile(filepath.Join(dir, "manifest.json")) + var manifest struct { + Data []string `json:"data"` + } + _ = json.Unmarshal(raw, &manifest) + last := manifest.Data[len(manifest.Data)-1] + if last != "views/menus.xml" { + t.Fatalf("geo menus.xml must be last; got %q", last) } +} + +func TestI18nManifestMenusLoadLast(t *testing.T) { + dir := addonDir(t, "i18n") + raw, _ := os.ReadFile(filepath.Join(dir, "manifest.json")) var manifest struct { Data []string `json:"data"` } - if err := json.Unmarshal(raw, &manifest); err != nil { - t.Fatal(err) + _ = json.Unmarshal(raw, &manifest) + last := manifest.Data[len(manifest.Data)-1] + if last != "views/menus.xml" { + t.Fatalf("i18n menus.xml must be last; got %q", last) } - if len(manifest.Data) == 0 { - t.Fatal("manifest data is empty") +} + +func TestBaseManifestMenusLoadLast(t *testing.T) { + dir := addonDir(t, "base") + raw, _ := os.ReadFile(filepath.Join(dir, "manifest.json")) + var manifest struct { + Data []string `json:"data"` } + _ = json.Unmarshal(raw, &manifest) last := manifest.Data[len(manifest.Data)-1] if last != "views/menus.xml" { - t.Fatalf("views/menus.xml must be last in manifest data; got %q", last) + t.Fatalf("base menus.xml must be last; got %q", last) } } From 8cbe08ee8508dfb2a80657eba7c526efd72a536e Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sun, 4 Oct 2026 01:39:56 +0530 Subject: [PATCH 11/15] addons/base slimmed --- addons/base/models/core_city.go | 14 ------- addons/base/models/core_country.go | 14 ------- addons/base/models/core_country_state.go | 14 ------- addons/base/models/core_currency.go | 13 ------- addons/base/models/core_lang.go | 14 ------- addons/base/models/sys_attachment.go | 20 ---------- addons/base/models/sys_audit.go | 18 --------- addons/base/models/sys_bulk_import.go | 30 -------------- addons/base/models/sys_config_parameter.go | 12 ------ addons/base/models/sys_import_template.go | 18 --------- addons/base/models/sys_report_action.go | 17 -------- addons/base/models/sys_sequence.go | 17 -------- addons/base/models/sys_translation.go | 14 ------- addons/base/views/core_city_form_views.xml | 15 ------- addons/base/views/core_city_list_views.xml | 11 ------ addons/base/views/core_country_form_views.xml | 15 ------- addons/base/views/core_country_list_views.xml | 11 ------ .../views/core_country_state_form_views.xml | 15 ------- .../views/core_country_state_list_views.xml | 11 ------ addons/base/views/core_lang_form_views.xml | 15 ------- addons/base/views/core_lang_list_views.xml | 11 ------ .../base/views/sys_attachment_form_views.xml | 18 --------- .../base/views/sys_attachment_list_views.xml | 12 ------ addons/base/views/sys_audit_form_views.xml | 23 ----------- addons/base/views/sys_audit_list_views.xml | 13 ------- addons/base/views/sys_bulk_import_actions.xml | 6 --- .../base/views/sys_bulk_import_form_views.xml | 39 ------------------- .../views/sys_config_parameter_form_views.xml | 13 ------- .../views/sys_config_parameter_list_views.xml | 9 ----- .../views/sys_import_template_actions.xml | 6 --- .../views/sys_import_template_form_views.xml | 21 ---------- .../views/sys_import_template_list_views.xml | 12 ------ .../base/views/sys_report_action_actions.xml | 39 ------------------- .../views/sys_report_action_form_views.xml | 18 --------- .../views/sys_report_action_list_views.xml | 12 ------ addons/base/views/sys_sequence_form_views.xml | 18 --------- addons/base/views/sys_sequence_list_views.xml | 12 ------ .../base/views/sys_translation_form_views.xml | 15 ------- .../base/views/sys_translation_list_views.xml | 11 ------ 39 files changed, 616 deletions(-) delete mode 100644 addons/base/models/core_city.go delete mode 100644 addons/base/models/core_country.go delete mode 100644 addons/base/models/core_country_state.go delete mode 100644 addons/base/models/core_currency.go delete mode 100644 addons/base/models/core_lang.go delete mode 100644 addons/base/models/sys_attachment.go delete mode 100644 addons/base/models/sys_audit.go delete mode 100644 addons/base/models/sys_bulk_import.go delete mode 100644 addons/base/models/sys_config_parameter.go delete mode 100644 addons/base/models/sys_import_template.go delete mode 100644 addons/base/models/sys_report_action.go delete mode 100644 addons/base/models/sys_sequence.go delete mode 100644 addons/base/models/sys_translation.go delete mode 100644 addons/base/views/core_city_form_views.xml delete mode 100644 addons/base/views/core_city_list_views.xml delete mode 100644 addons/base/views/core_country_form_views.xml delete mode 100644 addons/base/views/core_country_list_views.xml delete mode 100644 addons/base/views/core_country_state_form_views.xml delete mode 100644 addons/base/views/core_country_state_list_views.xml delete mode 100644 addons/base/views/core_lang_form_views.xml delete mode 100644 addons/base/views/core_lang_list_views.xml delete mode 100644 addons/base/views/sys_attachment_form_views.xml delete mode 100644 addons/base/views/sys_attachment_list_views.xml delete mode 100644 addons/base/views/sys_audit_form_views.xml delete mode 100644 addons/base/views/sys_audit_list_views.xml delete mode 100644 addons/base/views/sys_bulk_import_actions.xml delete mode 100644 addons/base/views/sys_bulk_import_form_views.xml delete mode 100644 addons/base/views/sys_config_parameter_form_views.xml delete mode 100644 addons/base/views/sys_config_parameter_list_views.xml delete mode 100644 addons/base/views/sys_import_template_actions.xml delete mode 100644 addons/base/views/sys_import_template_form_views.xml delete mode 100644 addons/base/views/sys_import_template_list_views.xml delete mode 100644 addons/base/views/sys_report_action_actions.xml delete mode 100644 addons/base/views/sys_report_action_form_views.xml delete mode 100644 addons/base/views/sys_report_action_list_views.xml delete mode 100644 addons/base/views/sys_sequence_form_views.xml delete mode 100644 addons/base/views/sys_sequence_list_views.xml delete mode 100644 addons/base/views/sys_translation_form_views.xml delete mode 100644 addons/base/views/sys_translation_list_views.xml diff --git a/addons/base/models/core_city.go b/addons/base/models/core_city.go deleted file mode 100644 index 5bb6d78c..00000000 --- a/addons/base/models/core_city.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type CoreCity struct { - sdk.Model `sumeru:"model=core.city"` - - Name sdk.String `sumeru:"required,string=City"` - StateID sdk.Many2One[CoreCountryState] `sumeru:"index,string=State"` - CountryID sdk.Many2One[CoreCountry] `sumeru:"required,index,string=Country"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/base/models/core_country.go b/addons/base/models/core_country.go deleted file mode 100644 index 886b4008..00000000 --- a/addons/base/models/core_country.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type CoreCountry struct { - sdk.Model `sumeru:"model=core.country"` - - Name sdk.String `sumeru:"required,string=Country"` - Code sdk.String `sumeru:"required,unique,index,string=Code"` - PhoneCode sdk.String `sumeru:"string=Phone Code"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/base/models/core_country_state.go b/addons/base/models/core_country_state.go deleted file mode 100644 index 25bc832b..00000000 --- a/addons/base/models/core_country_state.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type CoreCountryState struct { - sdk.Model `sumeru:"model=core.country.state"` - - Name sdk.String `sumeru:"required,string=State"` - Code sdk.String `sumeru:"string=Code"` - CountryID sdk.Many2One[CoreCountry] `sumeru:"required,index,string=Country"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/base/models/core_currency.go b/addons/base/models/core_currency.go deleted file mode 100644 index e9eb4f74..00000000 --- a/addons/base/models/core_currency.go +++ /dev/null @@ -1,13 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type CoreCurrency struct { - sdk.Model `sumeru:"model=core.currency"` - - Name sdk.String `sumeru:"required,unique,index,string=Currency"` - Symbol sdk.String `sumeru:"required,string=Symbol"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/base/models/core_lang.go b/addons/base/models/core_lang.go deleted file mode 100644 index 86fbcbe6..00000000 --- a/addons/base/models/core_lang.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type CoreLang struct { - sdk.Model `sumeru:"model=core.lang"` - - Code sdk.String `sumeru:"required,unique,index,string=Code"` - Name sdk.String `sumeru:"required,string=Name"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` - IsoCode sdk.String `sumeru:"string=ISO Code"` -} diff --git a/addons/base/models/sys_attachment.go b/addons/base/models/sys_attachment.go deleted file mode 100644 index 2cce1db1..00000000 --- a/addons/base/models/sys_attachment.go +++ /dev/null @@ -1,20 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type SysAttachment struct { - sdk.Model `sumeru:"model=sys.attachment"` - - Name sdk.String `sumeru:"required,string=Name"` - ResModel sdk.String `sumeru:"index,column=model,string=Model"` - ResID sdk.Integer `sumeru:"index,string=Record"` - ResField sdk.String `sumeru:"index,string=Field"` - Mimetype sdk.String `sumeru:"string=MIME Type"` - FileSize sdk.Integer `sumeru:"string=Size"` - Datas sdk.Text `sumeru:"string=Data"` - StoreFname sdk.String `sumeru:"string=Stored Filename"` - CreateDate sdk.DateTime `sumeru:"string=Created"` - CompanyID sdk.Many2One[CoreCompany] `sumeru:"string=Company"` -} diff --git a/addons/base/models/sys_audit.go b/addons/base/models/sys_audit.go deleted file mode 100644 index 462e6f20..00000000 --- a/addons/base/models/sys_audit.go +++ /dev/null @@ -1,18 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type SysAudit struct { - sdk.Model `sumeru:"model=sys.audit"` - - UserID sdk.Many2One[CoreUser] `sumeru:"index,string=User"` - Action sdk.String `sumeru:"required,index,string=Action"` - ResModel sdk.String `sumeru:"index,column=model,string=Model"` - ResID sdk.Integer `sumeru:"string=Record"` - BeforeJson sdk.Text `sumeru:"string=Before"` - AfterJson sdk.Text `sumeru:"string=After"` - Detail sdk.Text `sumeru:"string=Detail"` - CreateDate sdk.DateTime `sumeru:"required,index,string=When"` -} diff --git a/addons/base/models/sys_bulk_import.go b/addons/base/models/sys_bulk_import.go deleted file mode 100644 index b8fe1382..00000000 --- a/addons/base/models/sys_bulk_import.go +++ /dev/null @@ -1,30 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type SysBulkImport struct { - sdk.Model `sumeru:"model=sys.bulk.import"` - - Name sdk.String `sumeru:"required,string=Name"` - TargetModel sdk.String `sumeru:"required,string=Target Model"` - ImportMode sdk.String `sumeru:"string=Import Mode"` - AttachmentID sdk.Many2One[SysAttachment] `sumeru:"string=Staged File"` - SelectedFields sdk.Text `sumeru:"string=Selected Fields"` - CsvHeaders sdk.Text `sumeru:"string=CSV Headers"` - ColumnMapping sdk.Text `sumeru:"string=Column Mapping"` - PreviewJson sdk.Text `sumeru:"string=Preview"` - NextUrl sdk.String `sumeru:"string=Return URL"` - UserID sdk.Many2One[CoreUser] `sumeru:"string=User"` - ActionID sdk.Integer `sumeru:"string=Source Action"` - State sdk.String `sumeru:"string=State"` - Direction sdk.String `sumeru:"string=Direction"` - TemplateID sdk.Many2One[SysImportTemplate] `sumeru:"string=Import Template"` - ErrorJson sdk.Text `sumeru:"string=Errors"` - Progress sdk.Integer `sumeru:"string=Progress"` - TotalRows sdk.Integer `sumeru:"string=Total Rows"` - ResultSummary sdk.String `sumeru:"string=Result Summary"` - ExportFormat sdk.String `sumeru:"string=Export Format"` - ExportDomain sdk.Text `sumeru:"string=Export Domain"` -} diff --git a/addons/base/models/sys_config_parameter.go b/addons/base/models/sys_config_parameter.go deleted file mode 100644 index c560dc5f..00000000 --- a/addons/base/models/sys_config_parameter.go +++ /dev/null @@ -1,12 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type SysConfigParameter struct { - sdk.Model `sumeru:"model=sys.config.parameter"` - - Key sdk.String `sumeru:"required,unique,index,string=Key"` - Value sdk.Text `sumeru:"string=Value"` -} diff --git a/addons/base/models/sys_import_template.go b/addons/base/models/sys_import_template.go deleted file mode 100644 index 7e6f1ce3..00000000 --- a/addons/base/models/sys_import_template.go +++ /dev/null @@ -1,18 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type SysImportTemplate struct { - sdk.Model `sumeru:"model=sys.import.template"` - - Name sdk.String `sumeru:"required,string=Name"` - TargetModel sdk.String `sumeru:"required,index,string=Target Model"` - ImportMode sdk.String `sumeru:"string=Import Mode"` - SelectedFields sdk.Text `sumeru:"string=Selected Fields"` - ColumnMapping sdk.Text `sumeru:"string=Column Mapping"` - Shared sdk.Boolean `sumeru:"string=Shared"` - UserID sdk.Many2One[CoreUser] `sumeru:"string=User"` - CompanyID sdk.Many2One[CoreCompany] `sumeru:"string=Company"` -} diff --git a/addons/base/models/sys_report_action.go b/addons/base/models/sys_report_action.go deleted file mode 100644 index 62afd321..00000000 --- a/addons/base/models/sys_report_action.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type SysReportAction struct { - sdk.Model `sumeru:"model=sys.report.action"` - - Name sdk.String `sumeru:"required,string=Name"` - ResModel sdk.String `sumeru:"required,index,column=model,string=Model"` - ReportType sdk.String `sumeru:"string=Report Type,default=pdf"` - Arch sdk.Text `sumeru:"string=Document Arch"` - TemplatePath sdk.String `sumeru:"string=Template Path"` - Paperformat sdk.String `sumeru:"string=Paper Format,default=a4"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/base/models/sys_sequence.go b/addons/base/models/sys_sequence.go deleted file mode 100644 index 711eb2f7..00000000 --- a/addons/base/models/sys_sequence.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type SysSequence struct { - sdk.Model `sumeru:"model=sys.sequence"` - - Name sdk.String `sumeru:"required,string=Name"` - Code sdk.String `sumeru:"required,unique,index,string=Code"` - Prefix sdk.String `sumeru:"string=Prefix"` - Suffix sdk.String `sumeru:"string=Suffix"` - Padding sdk.Integer `sumeru:"string=Padding,default=5"` - NumberNext sdk.Integer `sumeru:"string=Next Number,default=1"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/base/models/sys_translation.go b/addons/base/models/sys_translation.go deleted file mode 100644 index 34621f3d..00000000 --- a/addons/base/models/sys_translation.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type SysTranslation struct { - sdk.Model `sumeru:"model=sys.translation"` - - Lang sdk.String `sumeru:"required,index,string=Language"` - Src sdk.Text `sumeru:"required,string=Source"` - Value sdk.Text `sumeru:"string=Translation"` - Module sdk.String `sumeru:"index,string=Module"` -} diff --git a/addons/base/views/core_city_form_views.xml b/addons/base/views/core_city_form_views.xml deleted file mode 100644 index a26bb1b8..00000000 --- a/addons/base/views/core_city_form_views.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/addons/base/views/core_city_list_views.xml b/addons/base/views/core_city_list_views.xml deleted file mode 100644 index 23e5e35c..00000000 --- a/addons/base/views/core_city_list_views.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/addons/base/views/core_country_form_views.xml b/addons/base/views/core_country_form_views.xml deleted file mode 100644 index 1cf8d7b1..00000000 --- a/addons/base/views/core_country_form_views.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/addons/base/views/core_country_list_views.xml b/addons/base/views/core_country_list_views.xml deleted file mode 100644 index 23cde8ec..00000000 --- a/addons/base/views/core_country_list_views.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/addons/base/views/core_country_state_form_views.xml b/addons/base/views/core_country_state_form_views.xml deleted file mode 100644 index 8466127b..00000000 --- a/addons/base/views/core_country_state_form_views.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/addons/base/views/core_country_state_list_views.xml b/addons/base/views/core_country_state_list_views.xml deleted file mode 100644 index 929208b2..00000000 --- a/addons/base/views/core_country_state_list_views.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/addons/base/views/core_lang_form_views.xml b/addons/base/views/core_lang_form_views.xml deleted file mode 100644 index 490e6a98..00000000 --- a/addons/base/views/core_lang_form_views.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/addons/base/views/core_lang_list_views.xml b/addons/base/views/core_lang_list_views.xml deleted file mode 100644 index dc66f6f0..00000000 --- a/addons/base/views/core_lang_list_views.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/addons/base/views/sys_attachment_form_views.xml b/addons/base/views/sys_attachment_form_views.xml deleted file mode 100644 index 702b6894..00000000 --- a/addons/base/views/sys_attachment_form_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/addons/base/views/sys_attachment_list_views.xml b/addons/base/views/sys_attachment_list_views.xml deleted file mode 100644 index b1ceb49e..00000000 --- a/addons/base/views/sys_attachment_list_views.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/addons/base/views/sys_audit_form_views.xml b/addons/base/views/sys_audit_form_views.xml deleted file mode 100644 index 66af77b8..00000000 --- a/addons/base/views/sys_audit_form_views.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/base/views/sys_audit_list_views.xml b/addons/base/views/sys_audit_list_views.xml deleted file mode 100644 index f7f47b6d..00000000 --- a/addons/base/views/sys_audit_list_views.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/addons/base/views/sys_bulk_import_actions.xml b/addons/base/views/sys_bulk_import_actions.xml deleted file mode 100644 index eed2fb9e..00000000 --- a/addons/base/views/sys_bulk_import_actions.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/addons/base/views/sys_bulk_import_form_views.xml b/addons/base/views/sys_bulk_import_form_views.xml deleted file mode 100644 index 22aa5e34..00000000 --- a/addons/base/views/sys_bulk_import_form_views.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -
    -
    diff --git a/addons/base/views/sys_config_parameter_form_views.xml b/addons/base/views/sys_config_parameter_form_views.xml deleted file mode 100644 index d2a595fa..00000000 --- a/addons/base/views/sys_config_parameter_form_views.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/addons/base/views/sys_config_parameter_list_views.xml b/addons/base/views/sys_config_parameter_list_views.xml deleted file mode 100644 index 5f92a7f7..00000000 --- a/addons/base/views/sys_config_parameter_list_views.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/addons/base/views/sys_import_template_actions.xml b/addons/base/views/sys_import_template_actions.xml deleted file mode 100644 index f7119275..00000000 --- a/addons/base/views/sys_import_template_actions.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/addons/base/views/sys_import_template_form_views.xml b/addons/base/views/sys_import_template_form_views.xml deleted file mode 100644 index fd6d7b5f..00000000 --- a/addons/base/views/sys_import_template_form_views.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/base/views/sys_import_template_list_views.xml b/addons/base/views/sys_import_template_list_views.xml deleted file mode 100644 index 4aa9b350..00000000 --- a/addons/base/views/sys_import_template_list_views.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/addons/base/views/sys_report_action_actions.xml b/addons/base/views/sys_report_action_actions.xml deleted file mode 100644 index 3ff1ea62..00000000 --- a/addons/base/views/sys_report_action_actions.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - Demo Platform Report - core.company - pdf - demo_platform_report - a4 - - - - Document Arch Demo - core.company - pdf - - Company Document - {{.Record.name}} -
    - - -
    - - ]]>
    - a4 - -
    - - - -

    Extended by inherit

    - - ]]>
    -
    -
    -
    diff --git a/addons/base/views/sys_report_action_form_views.xml b/addons/base/views/sys_report_action_form_views.xml deleted file mode 100644 index b9d9e136..00000000 --- a/addons/base/views/sys_report_action_form_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/addons/base/views/sys_report_action_list_views.xml b/addons/base/views/sys_report_action_list_views.xml deleted file mode 100644 index 777c6ce8..00000000 --- a/addons/base/views/sys_report_action_list_views.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/addons/base/views/sys_sequence_form_views.xml b/addons/base/views/sys_sequence_form_views.xml deleted file mode 100644 index e547d6ae..00000000 --- a/addons/base/views/sys_sequence_form_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/addons/base/views/sys_sequence_list_views.xml b/addons/base/views/sys_sequence_list_views.xml deleted file mode 100644 index 998ad4b0..00000000 --- a/addons/base/views/sys_sequence_list_views.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/addons/base/views/sys_translation_form_views.xml b/addons/base/views/sys_translation_form_views.xml deleted file mode 100644 index 6890f874..00000000 --- a/addons/base/views/sys_translation_form_views.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/addons/base/views/sys_translation_list_views.xml b/addons/base/views/sys_translation_list_views.xml deleted file mode 100644 index b42f0444..00000000 --- a/addons/base/views/sys_translation_list_views.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - From f605b20474e47e85f05e04e8c57554135ca60a34 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sun, 4 Oct 2026 01:41:00 +0530 Subject: [PATCH 12/15] workspace.ts updated recordmessages and record log initiated --- core/swc/src/types/workspace.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/swc/src/types/workspace.ts b/core/swc/src/types/workspace.ts index be04a1f9..35746bde 100644 --- a/core/swc/src/types/workspace.ts +++ b/core/swc/src/types/workspace.ts @@ -280,4 +280,6 @@ export interface SwcWorkspacePayload { defaults?: Record; iframeUrl?: string; userSecurity?: SwcUserSecurityMeta; + recordMessagesEligible?: boolean; + recordLogEligible?: boolean; } From 5b9ec0021cb4327fe22a9802af34a6f087c9a930 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sun, 4 Oct 2026 01:49:46 +0530 Subject: [PATCH 13/15] Use ProjectMeru go-qr for TOTP PNGs and bump SWC test deps. Replace skip2/go-qrcode with piglig/go-qr resolved to ProjectMeru/go-qr v1.1.0; refresh vitest 5.0.3 and jsdom 30.1.1. --- core/server/auth/totp_qr_png.go | 15 +- core/swc/package-lock.json | 428 +++++++++++++++----------------- core/swc/package.json | 6 +- go.mod | 5 +- go.sum | 12 +- 5 files changed, 230 insertions(+), 236 deletions(-) diff --git a/core/server/auth/totp_qr_png.go b/core/server/auth/totp_qr_png.go index bd564a1a..75908c17 100644 --- a/core/server/auth/totp_qr_png.go +++ b/core/server/auth/totp_qr_png.go @@ -1,11 +1,22 @@ package auth -import qrcode "github.com/skip2/go-qrcode" +import go_qr "github.com/piglig/go-qr" // resolved to github.com/ProjectMeru/go-qr via go.mod replace + +const totpQRQuietBorder = 4 // TOTPQRPng returns a PNG image for an otpauth URI. func TOTPQRPng(otpauthURI string, size int) ([]byte, error) { if size <= 0 { size = 200 } - return qrcode.Encode(otpauthURI, qrcode.Medium, size) + qr, err := go_qr.EncodeText(otpauthURI, go_qr.Medium) + if err != nil { + return nil, err + } + dim := qr.Size() + 2*totpQRQuietBorder + scale := size / dim + if scale < 1 { + scale = 1 + } + return qr.ToPNGBytes(go_qr.NewQrCodeImgConfig(scale, totpQRQuietBorder)) } diff --git a/core/swc/package-lock.json b/core/swc/package-lock.json index 95ede959..f886a949 100644 --- a/core/swc/package-lock.json +++ b/core/swc/package-lock.json @@ -11,44 +11,44 @@ "chart.js": "^4.5.1" }, "devDependencies": { - "@vitest/coverage-v8": "^5.0.0", + "@vitest/coverage-v8": "^5.0.3", "esbuild": "^0.28.2", - "jsdom": "^30.0.1", + "jsdom": "^30.1.1", "typescript": "^7.0.2", - "vitest": "^5.0.0" + "vitest": "^5.0.3" } }, "node_modules/@asamuzakjp/css-color": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", - "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.3.0", - "@csstools/css-color-parser": "^4.1.10", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" }, "engines": { - "node": "^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", - "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", "dev": true, "license": "MIT", "dependencies": { - "bidi-js": "^1.0.3", + "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" + "lru-cache": "^11.5.3" }, "engines": { - "node": "^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@babel/helper-string-parser": { @@ -125,9 +125,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", - "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", "dev": true, "funding": [ { @@ -145,9 +145,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", "dev": true, "funding": [ { @@ -164,14 +164,14 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", - "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", "dev": true, "funding": [ { @@ -185,21 +185,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.1.1", - "@csstools/css-calc": "^3.3.0" + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" }, "engines": { "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", "dev": true, "funding": [ { @@ -216,13 +216,13 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", - "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", "dev": true, "funding": [ { @@ -245,9 +245,9 @@ } }, "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", "dev": true, "funding": [ { @@ -707,9 +707,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", "dev": true, "license": "MIT", "engines": { @@ -759,9 +759,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.149.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", - "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "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==", "dev": true, "license": "MIT", "peer": true, @@ -770,9 +770,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", - "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "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==", "cpu": [ "arm" ], @@ -788,9 +788,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", - "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", "cpu": [ "arm64" ], @@ -806,9 +806,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", - "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "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==", "cpu": [ "arm64" ], @@ -824,9 +824,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", - "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "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==", "cpu": [ "x64" ], @@ -842,9 +842,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", - "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "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==", "cpu": [ "x64" ], @@ -860,9 +860,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", - "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "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==", "cpu": [ "arm" ], @@ -878,9 +878,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", - "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "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==", "cpu": [ "arm64" ], @@ -899,9 +899,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", - "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "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==", "cpu": [ "arm64" ], @@ -920,9 +920,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", - "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "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==", "cpu": [ "ppc64" ], @@ -941,9 +941,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", - "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "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==", "cpu": [ "s390x" ], @@ -962,9 +962,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", - "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "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==", "cpu": [ "x64" ], @@ -983,9 +983,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", - "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "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==", "cpu": [ "x64" ], @@ -1004,9 +1004,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", - "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", "cpu": [ "arm64" ], @@ -1022,9 +1022,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", - "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "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==", "cpu": [ "arm64" ], @@ -1040,9 +1040,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", - "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "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==", "cpu": [ "x64" ], @@ -1431,9 +1431,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", - "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.3.tgz", + "integrity": "sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1450,8 +1450,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "5.0.0", - "vitest": "5.0.0" + "@vitest/browser": "5.0.3", + "vitest": "5.0.3" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1483,14 +1483,14 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", - "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.0", + "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, @@ -1511,9 +1511,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", - "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", "dev": true, "license": "MIT", "funding": { @@ -1543,9 +1543,9 @@ } }, "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "dev": true, "license": "MIT", "dependencies": { @@ -1553,9 +1553,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -1636,9 +1636,9 @@ } }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -1752,16 +1752,16 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.6.0" + "@exodus/bytes": "^1.15.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/is-potential-custom-element-name": { @@ -1779,32 +1779,31 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", - "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^6.0.5", - "@asamuzakjp/dom-selector": "^8.3.0", + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", + "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", - "undici": "^8.9.0", - "w3c-xmlserializer": "^5.0.0", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.0", + "whatwg-url": "^17.1.1", "xml-name-validator": "^5.0.0" }, "engines": { @@ -2105,9 +2104,9 @@ } }, "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2115,9 +2114,9 @@ } }, "node_modules/magic-string": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", - "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { @@ -2262,14 +2261,14 @@ } }, "node_modules/rolldown": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", - "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@oxc-project/types": "=0.149.0", + "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2279,21 +2278,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.8", - "@rolldown/binding-android-arm64": "1.2.8", - "@rolldown/binding-darwin-arm64": "1.2.8", - "@rolldown/binding-darwin-x64": "1.2.8", - "@rolldown/binding-freebsd-x64": "1.2.8", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", - "@rolldown/binding-linux-arm64-gnu": "1.2.8", - "@rolldown/binding-linux-arm64-musl": "1.2.8", - "@rolldown/binding-linux-ppc64-gnu": "1.2.8", - "@rolldown/binding-linux-s390x-gnu": "1.2.8", - "@rolldown/binding-linux-x64-gnu": "1.2.8", - "@rolldown/binding-linux-x64-musl": "1.2.8", - "@rolldown/binding-openharmony-arm64": "1.2.8", - "@rolldown/binding-win32-arm64-msvc": "1.2.8", - "@rolldown/binding-win32-x64-msvc": "1.2.8" + "@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" } }, "node_modules/saxes": { @@ -2309,13 +2308,6 @@ "node": ">=v12.22.7" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2326,31 +2318,17 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", "dev": true, "license": "MIT", "engines": { @@ -2358,9 +2336,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -2395,22 +2373,22 @@ } }, "node_modules/tldts": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", - "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.10" + "tldts-core": "^7.4.16" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", - "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", "dev": true, "license": "MIT" }, @@ -2476,9 +2454,9 @@ } }, "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "dev": true, "license": "MIT", "engines": { @@ -2486,9 +2464,9 @@ } }, "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", "dev": true, "license": "MIT", "peer": true, @@ -2496,7 +2474,7 @@ "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", - "rolldown": "~1.2.6", + "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "bin": { @@ -2565,14 +2543,14 @@ } }, "node_modules/vitest": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", - "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.0", + "@vitest/mocker": "5.0.3", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -2580,10 +2558,10 @@ "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", - "why-is-node-running": "^2.3.0" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -2598,12 +2576,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.0", - "@vitest/browser-preview": "5.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.0", - "@vitest/coverage-v8": "5.0.0", - "@vitest/ui": "5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -2648,16 +2626,16 @@ } }, "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/webidl-conversions": { @@ -2681,9 +2659,9 @@ } }, "node_modules/whatwg-url": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", - "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2696,20 +2674,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/xml-name-validator": { diff --git a/core/swc/package.json b/core/swc/package.json index 10df1c36..719eb81f 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.0", + "@vitest/coverage-v8": "^5.0.3", "esbuild": "^0.28.2", - "jsdom": "^30.0.1", + "jsdom": "^30.1.1", "typescript": "^7.0.2", - "vitest": "^5.0.0" + "vitest": "^5.0.3" }, "dependencies": { "chart.js": "^4.5.1" diff --git a/go.mod b/go.mod index e89e88c4..2d9ebd37 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,13 @@ require ( github.com/gorilla/websocket v1.5.3 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 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) -require github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - replace github.com/gpdf-dev/gpdf => github.com/ProjectMeru/gpdf v1.0.13 replace github.com/gorilla/websocket => github.com/ProjectMeru/websocket v1.5.3 + +replace github.com/piglig/go-qr => github.com/ProjectMeru/go-qr v1.1.0 diff --git a/go.sum b/go.sum index 15e5cbeb..56a68352 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,23 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/ProjectMeru/go-qr v1.1.0 h1:yhjL1aA3NDapDdECV+msopJbmiScRewAcGCc42eYMVU= +github.com/ProjectMeru/go-qr v1.1.0/go.mod h1:z3s7Yw5eBZ4dVz8bmXG7Uo2+Xm8lTYUE0rTeSvfuykM= github.com/ProjectMeru/gpdf v1.0.13 h1:YWq6ySDRawuNGTQbUeQLBlkYPevcxaMs12NsnTf4kBE= github.com/ProjectMeru/gpdf v1.0.13/go.mod h1:QTnE2q2L+q9E1neWCZen/V/x9iLOuJ1jWHBtylimfAw= github.com/ProjectMeru/websocket v1.5.3 h1:X3/lutkNQab0fuf7FVRQ80mkazPleR372uVPUWd6HWw= github.com/ProjectMeru/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= -github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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= 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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From ec24b7c921271760854df4bba05636cfd6c802a7 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sun, 4 Oct 2026 02:21:32 +0530 Subject: [PATCH 14/15] Add IM direct messaging APIs and record-scoped activity audit log. Enable company IM settings, unread counts, user search, and web endpoints; expose record log eligibility without full sys.audit ACL. --- addons/im/README.md | 11 ++- addons/im/company_href.go | 23 ++++++ addons/im/direct.go | 80 +++++++++++++++++-- addons/im/manifest.json | 3 +- addons/im/models/im_message.go | 3 + addons/im/settings.go | 56 ++++++++++--- addons/im/unread.go | 57 +++++++++++++ .../views/core_company_form_inherit_views.xml | 13 +++ core/engine/render/branding.go | 4 +- core/engine/render/swc_bootstrap.go | 14 ++-- core/orm/audit.go | 45 +++++++++++ core/orm/testexports.go | 8 ++ core/server/web/swc_activity_log.go | 27 ++++--- core/server/web/swc_direct.go | 31 ++++++- core/server/web/swc_payload.go | 7 +- test/core/coverage/exports_test.go | 8 +- test/core/im/im_test.go | 7 ++ test/core/orm/record_audit_log_test.go | 24 ++++++ 18 files changed, 371 insertions(+), 50 deletions(-) create mode 100644 addons/im/company_href.go create mode 100644 addons/im/unread.go create mode 100644 addons/im/views/core_company_form_inherit_views.xml create mode 100644 test/core/orm/record_audit_log_test.go diff --git a/addons/im/README.md b/addons/im/README.md index ddb2c6be..37c285b6 100644 --- a/addons/im/README.md +++ b/addons/im/README.md @@ -3,8 +3,13 @@ P2P messaging between internal users via the shell activity dock **Messages** tab. - Model: `im.message` +- **Messages** = direct chat between internal users (not tied to the record you have open). +- **Log** = change history for the open record (`sys.audit` via the **audit** addon), visible when you can read that record. - No record chatter, email, activities, followers, or templates -- Company flag: `im_enabled` on `core.company` (falls back to legacy `mail_chatter_enabled` until migrated) -- **Log** tab in the activity dock remains `sys.audit` (from the **audit** addon) +- Company flag: `im_enabled` on `core.company` (falls back to legacy `mail_chatter_enabled` when present) -Upgrade from the old Discuss addon: install **im**, uninstall **mail**, and migrate direct message rows if needed. +- **Unread** count on the Messages tab; messages marked read when you open a thread (`is_read`). +- Optional **record link** on each message (`res_model` / `res_id`) when sent while a form record is open. +- **`GET /web/swc/direct/status`**: `enabled`, `unread`, `companyFormHref` for the activity UI. + +Upgrade from the old Discuss addon: install **im**, uninstall **mail**, and migrate direct message rows if needed. After model changes, run **`-u im`** so `is_read`, `res_model`, and `res_id` columns exist. diff --git a/addons/im/company_href.go b/addons/im/company_href.go new file mode 100644 index 00000000..e92319f6 --- /dev/null +++ b/addons/im/company_href.go @@ -0,0 +1,23 @@ +package im + +import ( + "context" + "fmt" + + "sumeru/core/orm" +) + +// CompanySettingsFormHref returns a workspace URL to edit company IM settings when the user may read core.company. +func CompanySettingsFormHref(ctx context.Context, companyID int) string { + if companyID <= 0 { + return "" + } + uid := orm.SecurityUID(ctx) + if uid <= 0 { + return "" + } + if err := orm.CheckModelAccess(ctx, uid, "core.company", "read"); err != nil { + return "" + } + return fmt.Sprintf("/web?model=core.company&view_type=form&id=%d", companyID) +} diff --git a/addons/im/direct.go b/addons/im/direct.go index 6257180b..121233b3 100644 --- a/addons/im/direct.go +++ b/addons/im/direct.go @@ -12,6 +12,11 @@ import ( const MessageModel = "im.message" +const ( + directUserDirectoryLimit = 80 + directUserSearchLimit = 40 +) + // SearchInternalUsers finds active internal users by name or login (excluding uid). func SearchInternalUsers(ctx context.Context, uid int, query string, limit int) ([]map[string]interface{}, error) { if orm.DB == nil { @@ -20,23 +25,45 @@ func SearchInternalUsers(ctx context.Context, uid int, query string, limit int) if err := orm.CheckModelAccess(ctx, uid, "core.user", "read"); err != nil { return nil, err } - if limit <= 0 || limit > 40 { - limit = 20 - } q := strings.TrimSpace(query) + if limit <= 0 { + if q == "" { + limit = directUserDirectoryLimit + } else { + limit = directUserSearchLimit + } + } + if q == "" && limit > directUserDirectoryLimit { + limit = directUserDirectoryLimit + } + if q != "" && limit > directUserSearchLimit { + limit = directUserSearchLimit + } domain := [][]interface{}{ {"active", "=", true}, {"user_type", "=", "internal"}, {"id", "!=", uid}, } - if q != "" { + for _, tok := range strings.Fields(q) { + pattern := ilikePattern(tok) domain = append(domain, []interface{}{"|"}) - domain = append(domain, []interface{}{"name", "ilike", q}) - domain = append(domain, []interface{}{"login", "ilike", q}) + domain = append(domain, []interface{}{"name", "ilike", pattern}) + domain = append(domain, []interface{}{"login", "ilike", pattern}) } return orm.SearchPage(ctx, "core.user", domain, limit, 0, "name ASC") } +func ilikePattern(token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "%" + } + if !strings.Contains(token, "%") { + return "%" + token + "%" + } + return token +} + // ListDirectConversations returns recent peers for uid with last message preview. func ListDirectConversations(ctx context.Context, uid int, limit int) ([]map[string]interface{}, error) { if orm.DB == nil { @@ -94,6 +121,7 @@ func ListDirectConversations(ctx context.Context, uid int, limit int) ([]map[str break } } + unreadByPeer := directUnreadBySender(ctx, uid) out := make([]map[string]interface{}, 0, len(order)) for _, peerID := range order { c := seen[peerID] @@ -103,11 +131,31 @@ func ListDirectConversations(ctx context.Context, uid int, limit int) ([]map[str "name": name, "preview": c.preview, "lastMessage": c.when, + "unreadCount": unreadByPeer[peerID], }) } return out, nil } +func directUnreadBySender(ctx context.Context, uid int) map[int]int { + out := map[int]int{} + rows, err := orm.Search(ctx, MessageModel, [][]interface{}{ + {"recipient_id", "=", uid}, + {"is_read", "=", false}, + }) + if err != nil { + return out + } + for _, row := range rows { + sid, _ := orm.CoerceInt64(row["sender_id"]) + if sid <= 0 { + continue + } + out[int(sid)]++ + } + return out +} + // ListDirectThread returns messages between uid and peerID oldest first. func ListDirectThread(ctx context.Context, uid, peerID int) ([]map[string]interface{}, error) { if peerID <= 0 || peerID == uid { @@ -142,14 +190,18 @@ func ListDirectThread(ctx context.Context, uid, peerID int) ([]map[string]interf for _, row := range rows { msgID, _ := orm.CoerceInt64(row["id"]) sid, _ := orm.CoerceInt64(row["sender_id"]) + resID, _ := orm.CoerceInt64(row["res_id"]) out = append(out, map[string]interface{}{ "id": int(msgID), "body": strings.TrimSpace(orm.AsString(row["body"])), "createDate": strings.TrimSpace(orm.AsString(row["create_date"])), "outgoing": int(sid) == uid, + "resModel": strings.TrimSpace(orm.AsString(row["res_model"])), + "resId": int(resID), "attachments": listMessageAttachments(ctx, uid, int(msgID)), }) } + _ = MarkDirectThreadRead(ctx, uid, peerID) return out, nil } @@ -181,6 +233,11 @@ func listMessageAttachments(ctx context.Context, uid, messageID int) []map[strin // PostDirectMessage sends an internal direct message from uid to recipientID. func PostDirectMessage(ctx context.Context, uid, recipientID int, body string) (int, error) { + return PostDirectMessageLinked(ctx, uid, recipientID, body, "", 0) +} + +// PostDirectMessageLinked optionally attaches a related record (res_model / res_id). +func PostDirectMessageLinked(ctx context.Context, uid, recipientID int, body, resModel string, resID int) (int, error) { body = strings.TrimSpace(body) if body == "" { return 0, fmt.Errorf("message required") @@ -195,12 +252,19 @@ func PostDirectMessage(ctx context.Context, uid, recipientID int, body string) ( if !ok { return 0, fmt.Errorf("internal chat not available") } - id, err := orm.Create(ctx, inst, map[string]interface{}{ + vals := map[string]interface{}{ "sender_id": uid, "recipient_id": recipientID, "body": body, "create_date": time.Now().UTC(), - }) + "is_read": false, + } + resModel = strings.TrimSpace(resModel) + if resModel != "" && resID > 0 { + vals["res_model"] = resModel + vals["res_id"] = resID + } + id, err := orm.Create(ctx, inst, vals) if err != nil { return 0, err } diff --git a/addons/im/manifest.json b/addons/im/manifest.json index 865608d3..af8d8faf 100644 --- a/addons/im/manifest.json +++ b/addons/im/manifest.json @@ -8,6 +8,7 @@ "auto_install": true, "application": false, "data": [ - "security/sys.access.csv" + "security/sys.access.csv", + "views/core_company_form_inherit_views.xml" ] } diff --git a/addons/im/models/im_message.go b/addons/im/models/im_message.go index 18ddb0d5..c16b2526 100644 --- a/addons/im/models/im_message.go +++ b/addons/im/models/im_message.go @@ -11,4 +11,7 @@ type ImMessage struct { RecipientID sdk.Many2One[CoreUser] `sumeru:"required,index,string=To"` Body sdk.Text `sumeru:"string=Message"` CreateDate sdk.DateTime `sumeru:"required,index,string=Sent"` + IsRead sdk.Boolean `sumeru:"default=false,index,column=is_read,string=Read"` + ResModel sdk.String `sumeru:"index,column=res_model,string=Related document model"` + ResID sdk.Integer `sumeru:"index,string=Related document id"` } diff --git a/addons/im/settings.go b/addons/im/settings.go index 051cf81f..447ff483 100644 --- a/addons/im/settings.go +++ b/addons/im/settings.go @@ -3,6 +3,7 @@ package im import ( "context" "database/sql" + "strings" "sumeru/core/orm" ) @@ -16,23 +17,52 @@ func IMEnabled(ctx context.Context) bool { if companyID <= 0 { companyID = orm.ActiveCompanyIDForUser(ctx, orm.SecurityUID(ctx)) } + if enabled, ok := readIMEnabledColumn(ctx, int(companyID)); ok { + return enabled + } + if enabled, ok := readLegacyChatterColumn(ctx, int(companyID)); ok { + return enabled + } + return true +} + +func readIMEnabledColumn(ctx context.Context, companyID int) (enabled bool, ok bool) { tn := orm.MustQuotedTableName("core.company") - var legacyChatter sql.NullBool - q := `SELECT mail_chatter_enabled FROM ` + tn + q := `SELECT im_enabled FROM ` + tn + var imEnabled sql.NullBool + var err error if companyID > 0 { - q += ` WHERE id = $1` - err := orm.DB.QueryRowContext(ctx, q, companyID).Scan(&legacyChatter) - if err != nil { - return true - } + err = orm.DB.QueryRowContext(ctx, q+` WHERE id = $1`, companyID).Scan(&imEnabled) } else { - q += ` ORDER BY id ASC LIMIT 1` - if err := orm.DB.QueryRowContext(ctx, q).Scan(&legacyChatter); err != nil { - return true + err = orm.DB.QueryRowContext(ctx, q+` ORDER BY id ASC LIMIT 1`).Scan(&imEnabled) + } + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "im_enabled") { + return false, false } + return true, true } - if legacyChatter.Valid { - return legacyChatter.Bool + if imEnabled.Valid { + return imEnabled.Bool, true } - return true + return true, true +} + +func readLegacyChatterColumn(ctx context.Context, companyID int) (enabled bool, ok bool) { + tn := orm.MustQuotedTableName("core.company") + q := `SELECT mail_chatter_enabled FROM ` + tn + var legacy sql.NullBool + var err error + if companyID > 0 { + err = orm.DB.QueryRowContext(ctx, q+` WHERE id = $1`, companyID).Scan(&legacy) + } else { + err = orm.DB.QueryRowContext(ctx, q+` ORDER BY id ASC LIMIT 1`).Scan(&legacy) + } + if err != nil { + return true, true + } + if legacy.Valid { + return legacy.Bool, true + } + return true, true } diff --git a/addons/im/unread.go b/addons/im/unread.go new file mode 100644 index 00000000..4e9e8d1d --- /dev/null +++ b/addons/im/unread.go @@ -0,0 +1,57 @@ +package im + +import ( + "context" + + "sumeru/core/orm" +) + +// UnreadDirectCount returns incoming messages not yet marked read for uid. +func UnreadDirectCount(ctx context.Context, uid int) (int, error) { + if uid <= 0 || orm.DB == nil { + return 0, nil + } + if err := orm.CheckModelAccess(ctx, uid, MessageModel, "read"); err != nil { + return 0, err + } + if _, ok := orm.Registry[MessageModel]; !ok { + return 0, nil + } + domain := [][]interface{}{ + {"recipient_id", "=", uid}, + {"is_read", "=", false}, + } + rows, err := orm.Search(ctx, MessageModel, domain) + if err != nil { + return 0, err + } + return len(rows), nil +} + +// MarkDirectThreadRead sets is_read on incoming messages in a peer thread. +func MarkDirectThreadRead(ctx context.Context, uid, peerID int) error { + if uid <= 0 || peerID <= 0 { + return nil + } + if err := orm.CheckModelAccess(ctx, uid, MessageModel, "write"); err != nil { + return err + } + rows, err := orm.Search(ctx, MessageModel, [][]interface{}{ + {"sender_id", "=", peerID}, + {"recipient_id", "=", uid}, + {"is_read", "=", false}, + }) + if err != nil { + return err + } + for _, row := range rows { + id, _ := orm.CoerceInt64(row["id"]) + if id <= 0 { + continue + } + if _, err := orm.Update(ctx, MessageModel, [][]interface{}{{"id", "=", int(id)}}, map[string]interface{}{"is_read": true}); err != nil { + return err + } + } + return nil +} diff --git a/addons/im/views/core_company_form_inherit_views.xml b/addons/im/views/core_company_form_inherit_views.xml new file mode 100644 index 00000000..7c9ebf6b --- /dev/null +++ b/addons/im/views/core_company_form_inherit_views.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/core/engine/render/branding.go b/core/engine/render/branding.go index 04d19f23..b98c5d6e 100644 --- a/core/engine/render/branding.go +++ b/core/engine/render/branding.go @@ -5,7 +5,6 @@ import ( "html/template" "strings" - "sumeru/addons/im" "sumeru/core/orm" "sumeru/core/server/config" ) @@ -110,7 +109,8 @@ func EnrichShellPageData(ctx context.Context, d *PageData) { } d.ShellUserInitials = UserInitialsFromName(d.ShellUser) - d.ActivityEnabled = im.IMEnabled(ctx) + // Activity dock (Messages + Log) stays visible for logged-in users; IM APIs gate chat separately. + d.ActivityEnabled = orm.SecurityUID(ctx) > 0 if len(d.ExtraScriptURLs) == 0 { d.ExtraScriptURLs = ExtraScriptURLs } diff --git a/core/engine/render/swc_bootstrap.go b/core/engine/render/swc_bootstrap.go index ba91425e..33d9dbdb 100644 --- a/core/engine/render/swc_bootstrap.go +++ b/core/engine/render/swc_bootstrap.go @@ -37,7 +37,9 @@ type swcBootstrap struct { PinnedApps []string `json:"pinnedApps"` AppsNavAllowed bool `json:"appsNavAllowed"` SettingsNavAllowed bool `json:"settingsNavAllowed"` - ActivityEnabled bool `json:"activityEnabled"` + ActivityEnabled bool `json:"activityEnabled"` + InternalMessagesEnabled bool `json:"internalMessagesEnabled"` + CompanyFormHref string `json:"companyFormHref,omitempty"` BusEnabled bool `json:"busEnabled"` DocsURL string `json:"docsUrl"` ProfileURL string `json:"profileUrl"` @@ -100,7 +102,9 @@ func BuildSWCBootstrapJSON(ctx context.Context, page PageData, ws *SWCBootstrapW ActiveMenuID: page.ActiveMenuID, AppsNavAllowed: page.AppsNavAllowed, SettingsNavAllowed: page.SettingsNavAllowed, - ActivityEnabled: shellActivityEnabled(ctx, page), + ActivityEnabled: shellActivityEnabled(ctx, page), + InternalMessagesEnabled: im.IMEnabled(ctx), + CompanyFormHref: im.CompanySettingsFormHref(ctx, page.ShellActiveCompanyID), BusEnabled: true, DocsURL: page.UserDocsHref, ProfileURL: page.UserProfileHref, @@ -191,13 +195,11 @@ func parseSWCLauncherApps(raw template.JS) []swcBootstrapApp { } func shellActivityEnabled(ctx context.Context, page PageData) bool { + _ = ctx if page.SuppressActivityDock { return false } - if page.ActivityEnabled { - return true - } - return im.IMEnabled(ctx) + return page.ActivityEnabled } func parseSWCPinnedApps(raw template.JS) []string { diff --git a/core/orm/audit.go b/core/orm/audit.go index 22ffab65..a889dbea 100644 --- a/core/orm/audit.go +++ b/core/orm/audit.go @@ -3,6 +3,7 @@ package orm import ( "context" "encoding/json" + "fmt" "strings" "time" @@ -80,3 +81,47 @@ func scrubAuditMap(m map[string]interface{}) map[string]interface{} { func LogAccessDeny(ctx context.Context, model, op, detail string) { AppendAudit(ctx, "access_deny", model, 0, nil, nil, op+": "+detail) } + +const recordAuditLogMax = 40 + +// RecordAuditLogAvailable reports whether the audit module materialized sys.audit. +func RecordAuditLogAvailable(ctx context.Context) bool { + if _, ok := Registry["sys.audit"]; !ok { + return false + } + if DB == nil { + return true + } + installed, err := InstalledModuleNames(ctx) + if err != nil { + return true + } + if len(installed) == 0 { + return true + } + _, ok := installed["audit"] + return ok +} + +// SearchRecordAuditLog returns audit rows for one record. Caller must verify model read access. +func SearchRecordAuditLog(ctx context.Context, model string, resID int, limit, offset int) ([]map[string]interface{}, error) { + model = strings.TrimSpace(model) + if model == "" || resID <= 0 { + return nil, fmt.Errorf("invalid record") + } + if !RecordAuditLogAvailable(ctx) { + return nil, nil + } + if limit <= 0 || limit > recordAuditLogMax { + limit = recordAuditLogMax + } + if offset < 0 { + offset = 0 + } + domain := [][]interface{}{ + {"model", "=", model}, + {"res_id", "=", resID}, + } + elevated := AuditedBypass(ctx, "activity_log.record_timeline") + return SearchPage(elevated, "sys.audit", domain, limit, offset, "create_date DESC") +} diff --git a/core/orm/testexports.go b/core/orm/testexports.go index ffe10635..26730859 100644 --- a/core/orm/testexports.go +++ b/core/orm/testexports.go @@ -39,6 +39,14 @@ func FormatAuditLogBodyForTest(ctx context.Context, model, action, beforeJSON, a return FormatAuditLogBody(ctx, model, action, beforeJSON, afterJSON, detail) } +func RecordAuditLogAvailableForTest(ctx context.Context) bool { + return RecordAuditLogAvailable(ctx) +} + +func SearchRecordAuditLogForTest(ctx context.Context, model string, resID int, limit, offset int) ([]map[string]interface{}, error) { + return SearchRecordAuditLog(ctx, model, resID, limit, offset) +} + func SplitDomainORPrefixForTest(domain [][]interface{}) (orCount int, leaves [][]interface{}, ok bool) { return splitDomainORPrefix(domain) } diff --git a/core/server/web/swc_activity_log.go b/core/server/web/swc_activity_log.go index a41a379b..6f963041 100644 --- a/core/server/web/swc_activity_log.go +++ b/core/server/web/swc_activity_log.go @@ -23,8 +23,10 @@ type swcActivityLogItem struct { } type swcActivityLogPayload struct { - Items []swcActivityLogItem `json:"items"` - HasLog bool `json:"hasLog"` + Items []swcActivityLogItem `json:"items"` + HasLog bool `json:"hasLog"` + HasMore bool `json:"hasMore,omitempty"` + Offset int `json:"offset,omitempty"` } // SwcActivityLogHandler GET /web/swc/activity-log?model=&id= — sys.audit rows for the Log tab only (not mail.message). @@ -45,24 +47,30 @@ func SwcActivityLogHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "access denied", http.StatusForbidden) return } - if err := orm.CheckModelAccess(ctx, uid, "sys.audit", "read"); err != nil { - http.Error(w, "access denied", http.StatusForbidden) + if !orm.RecordAuditLogAvailable(ctx) { + http.Error(w, "audit not available", http.StatusNotFound) return } if _, err := orm.SearchOne(ctx, model, map[string]interface{}{"id": int(recordID)}); err != nil { http.NotFound(w, r) return } - domain := [][]interface{}{ - {"model", "=", model}, - {"res_id", "=", int(recordID)}, + offset, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("offset"))) + if offset < 0 { + offset = 0 } - rows, err := orm.SearchPage(ctx, "sys.audit", domain, 40, 0, "create_date DESC") + const pageSize = 40 + rows, err := orm.SearchRecordAuditLog(ctx, model, int(recordID), pageSize, offset) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - out := swcActivityLogPayload{Items: make([]swcActivityLogItem, 0, len(rows))} + out := swcActivityLogPayload{ + HasLog: true, + Offset: offset, + HasMore: len(rows) >= pageSize, + Items: make([]swcActivityLogItem, 0, len(rows)), + } for _, row := range rows { action := strings.TrimSpace(orm.AsString(row["action"])) detail := strings.TrimSpace(orm.AsString(row["detail"])) @@ -94,7 +102,6 @@ func SwcActivityLogHandler(w http.ResponseWriter, r *http.Request) { CreateDate: when, }) } - out.HasLog = len(out.Items) > 0 w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(out) } diff --git a/core/server/web/swc_direct.go b/core/server/web/swc_direct.go index 287e141d..2d3c3647 100644 --- a/core/server/web/swc_direct.go +++ b/core/server/web/swc_direct.go @@ -18,9 +18,11 @@ const ( swcDirectThreadRoute = "/web/swc/direct/thread" swcDirectPostRoute = "/web/swc/direct/post" swcDirectUploadRoute = "/web/swc/direct/upload" + swcDirectStatusRoute = "/web/swc/direct/status" ) func registerSwcDirectRoutes() { + registerSession(http.MethodGet, swcDirectStatusRoute, SwcDirectStatusHandler) registerSession(http.MethodGet, swcDirectUsersRoute, SwcDirectUsersHandler) registerSession(http.MethodGet, swcDirectConversationsRoute, SwcDirectConversationsHandler) registerSession(http.MethodGet, swcDirectThreadRoute, SwcDirectThreadHandler) @@ -28,6 +30,31 @@ func registerSwcDirectRoutes() { registerSession(http.MethodPost, swcDirectUploadRoute, SwcDirectUploadHandler) } +// SwcDirectStatusHandler GET /web/swc/direct/status — IM enabled flag, unread count, company settings link. +func SwcDirectStatusHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + ctx := r.Context() + uid := AuthenticatedUserID(r) + companyID := int(orm.CompanyIDFromContext(ctx)) + if companyID <= 0 { + companyID = int(orm.ActiveCompanyIDForUser(ctx, uid)) + } + unread := 0 + if im.IMEnabled(ctx) { + if n, err := im.UnreadDirectCount(ctx, uid); err == nil { + unread = n + } + } + writeJSONResponse(w, map[string]interface{}{ + "enabled": im.IMEnabled(ctx), + "unread": unread, + "companyId": companyID, + "companyFormHref": im.CompanySettingsFormHref(ctx, companyID), + }) +} + // SwcDirectUsersHandler GET /web/swc/direct/users?q= — internal user search for Messages tab. func SwcDirectUsersHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { @@ -108,8 +135,10 @@ func SwcDirectPostHandler(w http.ResponseWriter, r *http.Request) { } recipientID, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("recipient_id"))) body := strings.TrimSpace(r.PostFormValue("body")) + resModel := strings.TrimSpace(r.PostFormValue("res_model")) + resID, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("res_id"))) uid := AuthenticatedUserID(r) - id, err := im.PostDirectMessage(r.Context(), uid, recipientID, body) + id, err := im.PostDirectMessageLinked(r.Context(), uid, recipientID, body, resModel, resID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/core/server/web/swc_payload.go b/core/server/web/swc_payload.go index 71a3957f..0a9f3b82 100644 --- a/core/server/web/swc_payload.go +++ b/core/server/web/swc_payload.go @@ -80,14 +80,17 @@ func buildSwcWorkspacePayload( func applyActivityRecordFlags(ctx context.Context, payload *swcmeta.WorkspacePayload, viewMode string, recordID int, model string) { _ = viewMode - if payload == nil || recordID <= 0 || model == "" { + if payload == nil || model == "" { return } uid := orm.SecurityUID(ctx) if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil { return } - payload.RecordLogEligible = orm.CheckModelAccess(ctx, uid, "sys.audit", "read") == nil + if !orm.RecordAuditLogAvailable(ctx) { + return + } + payload.RecordLogEligible = true } func serializeSwcViewTabs(tabs []render.ViewSwitchTab) []swcmeta.ViewTab { diff --git a/test/core/coverage/exports_test.go b/test/core/coverage/exports_test.go index cb209e70..42227e20 100644 --- a/test/core/coverage/exports_test.go +++ b/test/core/coverage/exports_test.go @@ -399,12 +399,12 @@ func TestForTestExports_web(t *testing.T) { } payload := &swcmeta.WorkspacePayload{} web.ApplyActivityRecordFlagsForTest(context.Background(), payload, "list", 0, "core.user") - if payload.RecordMessagesEligible || payload.RecordLogEligible { - t.Fatal("record id 0 should not enable activity flags") + if payload.RecordMessagesEligible { + t.Fatal("record messages flag should stay off") } web.ApplyActivityRecordFlagsForTest(context.Background(), payload, "form", 0, "core.user") - if payload.RecordMessagesEligible || payload.RecordLogEligible { - t.Fatal("unsaved form should not enable activity flags") + if payload.RecordMessagesEligible { + t.Fatal("record messages flag should stay off on unsaved form") } if d, err := audit.RetentionDaysFromRecordForTest(map[string]interface{}{"retention_preset": "7_days"}); err != nil || d != 7 { t.Fatalf("retention 7d: %d %v", d, err) diff --git a/test/core/im/im_test.go b/test/core/im/im_test.go index b41cc13f..eb80aca0 100644 --- a/test/core/im/im_test.go +++ b/test/core/im/im_test.go @@ -43,6 +43,13 @@ func TestPostDirectMessage_requiresBody(t *testing.T) { } } +func TestSearchInternalUsers_nilDB(t *testing.T) { + users, err := im.SearchInternalUsers(context.Background(), 1, "john doe", 0) + if err != nil || len(users) != 0 { + t.Fatalf("nil db: %v %v", users, err) + } +} + func TestDirectHelpers_nilDB(t *testing.T) { ctx := context.Background() users, err := im.SearchInternalUsers(ctx, 1, "a", 5) diff --git a/test/core/orm/record_audit_log_test.go b/test/core/orm/record_audit_log_test.go new file mode 100644 index 00000000..c2601afc --- /dev/null +++ b/test/core/orm/record_audit_log_test.go @@ -0,0 +1,24 @@ +package orm_test + +import ( + "context" + "testing" + + "sumeru/core/orm" +) + +func TestSearchRecordAuditLog_invalidRecord(t *testing.T) { + _, err := orm.SearchRecordAuditLogForTest(context.Background(), "", 0, 10, 0) + if err == nil { + t.Fatal("expected error for empty model") + } +} + +func TestRecordAuditLogAvailable_withoutAuditModel(t *testing.T) { + if _, ok := orm.Registry["sys.audit"]; ok { + t.Skip("sys.audit registered in this test run") + } + if orm.RecordAuditLogAvailableForTest(context.Background()) { + t.Fatal("expected false without sys.audit model") + } +} From 22d0cb8336f59d7a551fbfdf89e9bc4631101698 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Sun, 4 Oct 2026 02:21:38 +0530 Subject: [PATCH 15/15] Polish Messages tab with chat layout, fuzzy search, and activity Log UI. Bubble thread with pinned composer, sidebar roster, and tests for panel, fuzzy match, and shell storage defaults. --- core/engine/assets/css/sumeru-messages.css | 508 +++++++++++---- core/engine/assets/css/sumeru-shell.css | 14 + core/engine/assets/css/sumeru-theme.css | 2 +- core/swc/src/shell/activity-context.ts | 12 - core/swc/src/shell/activity-im-banner.ts | 42 ++ core/swc/src/shell/activity-log.ts | 68 +- core/swc/src/shell/activity-panel.ts | 20 +- core/swc/src/shell/app-launcher.ts | 19 +- core/swc/src/shell/internal-messages-panel.ts | 580 ++++++++++++++---- core/swc/src/shell/messages-unread.ts | 28 + core/swc/src/shell/shell-chrome.ts | 5 + core/swc/src/types/bootstrap.ts | 2 + core/swc/src/util/format-relative-time.ts | 17 + core/swc/src/util/fuzzy.ts | 35 ++ core/swc/src/util/im-draft.ts | 26 + core/swc/src/util/shell-storage.ts | 5 +- core/swc/src/util/user-initials.ts | 11 + core/swc/tests/shell/activity-log.test.ts | 52 ++ .../shell/internal-messages-panel.test.ts | 101 +++ core/swc/tests/util/fuzzy.test.ts | 17 + core/swc/tests/util/shell-storage.test.ts | 6 +- 21 files changed, 1280 insertions(+), 290 deletions(-) create mode 100644 core/swc/src/shell/activity-im-banner.ts create mode 100644 core/swc/src/shell/messages-unread.ts create mode 100644 core/swc/src/util/format-relative-time.ts create mode 100644 core/swc/src/util/fuzzy.ts create mode 100644 core/swc/src/util/im-draft.ts create mode 100644 core/swc/src/util/user-initials.ts create mode 100644 core/swc/tests/shell/activity-log.test.ts create mode 100644 core/swc/tests/shell/internal-messages-panel.test.ts create mode 100644 core/swc/tests/util/fuzzy.test.ts diff --git a/core/engine/assets/css/sumeru-messages.css b/core/engine/assets/css/sumeru-messages.css index b39e601a..02a527f2 100644 --- a/core/engine/assets/css/sumeru-messages.css +++ b/core/engine/assets/css/sumeru-messages.css @@ -1,10 +1,11 @@ -/* Activity panel — Messages tab: empty state, thread cards, composer. */ +/* Activity panel — Messages tab: sidebar, chat thread, bottom composer. */ .sum-activity-pane--messages .sum-msg-shell { flex: 1; min-height: 0; display: flex; flex-direction: column; + overflow: hidden; } .sum-msg-empty { @@ -43,62 +44,219 @@ flex-direction: column; min-height: 0; flex: 1; + overflow: hidden; } -.sum-msg-thread { - flex: 1 1 auto; +.sum-msg-shell--dock { + height: 100%; + min-height: 12rem; +} + +.sum-msg-banner { + flex-shrink: 0; + margin: 0 0 0.4rem; + padding: 0.4rem 0.55rem; + font-size: 0.6875rem; + line-height: 1.4; + color: var(--sum-muted-ink); + background: color-mix(in srgb, var(--sum-warning, #f59e0b) 12%, var(--sum-surface)); + border: 1px solid color-mix(in srgb, var(--sum-warning, #f59e0b) 35%, var(--sum-line)); + border-radius: var(--sum-radius-sm); +} + +.sum-msg-layout { + display: flex; + flex-direction: column; + gap: 0; + flex: 1; + min-height: 0; + overflow: hidden; + border: 1px solid var(--sum-line); + border-radius: var(--sum-radius-md, var(--sum-radius-sm)); + background: var(--sum-surface); +} + +@media (min-width: 360px) { + .sum-activity-pane--messages .sum-msg-layout { + flex-direction: row; + } + + .sum-activity-pane--messages .sum-msg-sidebar { + flex: 0 0 42%; + max-width: 10.5rem; + min-width: 0; + border-right: 1px solid var(--sum-line); + border-bottom: none; + } + + .sum-activity-pane--messages .sum-msg-main { + flex: 1; + min-width: 0; + } +} + +.sum-msg-sidebar { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + min-height: 0; + padding: 0.45rem 0.4rem; + border-bottom: 1px solid var(--sum-line); + background: var(--sum-surface-alt); +} + +.sum-msg-sidebar-scroll { + flex: 1; min-height: 0; overflow: auto; - padding: 0.15rem 0.15rem 0.65rem; display: flex; flex-direction: column; - gap: 0.55rem; + gap: 0.25rem; } -.sum-msg-card { - border-radius: var(--sum-radius-sm); - padding: 0.55rem 0.65rem; - background: var(--sum-surface); +.sum-msg-search { + width: 100%; + box-sizing: border-box; + padding: 0.45rem 0.55rem; + font-size: var(--sum-font-size-sm); border: 1px solid var(--sum-line); - box-shadow: var(--sum-shadow-sm); + border-radius: 999px; + background: var(--sum-surface); } -.sum-msg-card-head { +.sum-msg-search:focus { + outline: none; + border-color: color-mix(in srgb, var(--sum-header) 40%, var(--sum-line)); + box-shadow: var(--sum-focus-ring); +} + +.sum-msg-peer-list { + list-style: none; + margin: 0; + padding: 0; display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.5rem; - margin-bottom: 0.35rem; + flex-direction: column; + gap: 0.15rem; } -.sum-msg-author { +.sum-msg-section-label { + margin: 0.35rem 0 0.15rem; + padding: 0 0.15rem; + font-size: 0.625rem; + font-weight: var(--sum-font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--sum-muted); +} + +.sum-msg-section-label:first-child { + margin-top: 0; +} + +.sum-msg-picker-empty { + padding: 0.65rem 0.35rem; + font-size: var(--sum-font-size-sm); + color: var(--sum-muted); +} + +.sum-msg-peer-btn { + width: 100%; + text-align: left; + padding: 0.4rem 0.45rem; + border: none; + border-radius: var(--sum-radius-sm); + background: transparent; + cursor: pointer; + transition: background var(--sum-dur) var(--sum-ease); +} + +.sum-msg-peer-row { + display: flex; + align-items: center; + gap: 0.45rem; + width: 100%; +} + +.sum-msg-avatar { + flex-shrink: 0; + width: 1.85rem; + height: 1.85rem; + border-radius: 999px; + background: var(--sum-header-tint-12); + color: var(--sum-header); + font-size: 0.65rem; + font-weight: var(--sum-font-weight-semibold); + display: flex; + align-items: center; + justify-content: center; +} + +.sum-msg-peer-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.05rem; +} + +.sum-msg-peer-text .sum-msg-author { font-size: var(--sum-font-size-sm); font-weight: var(--sum-font-weight-semibold); color: var(--sum-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.sum-msg-time { - font-size: 0.6875rem; +.sum-msg-peer-text .sum-msg-time { + font-size: 0.625rem; color: var(--sum-muted); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } -.sum-msg-body { - font-size: var(--sum-font-size-sm); - line-height: 1.45; - color: var(--sum-muted-ink); - white-space: pre-wrap; - word-break: break-word; +.sum-msg-unread-dot { + flex-shrink: 0; + min-width: 1.1rem; + padding: 0 0.25rem; + font-size: 0.6rem; + font-weight: 700; + line-height: 1.1rem; + text-align: center; + border-radius: 999px; + background: var(--sum-danger, #dc2626); + color: #fff; +} + +.sum-msg-peer-btn.is-active { + background: var(--sum-header-tint-12); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sum-header) 25%, transparent); +} + +.sum-msg-peer-btn.is-highlight, +.sum-msg-peer-btn:hover { + background: var(--sum-row-hover-bg); } -.sum-msg-thread-empty { +.sum-msg-main { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sum-msg-welcome { flex: 1; + min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; - padding: 1.25rem 0.75rem 0.5rem; + padding: 1rem 0.75rem; color: var(--sum-muted); } @@ -111,38 +269,176 @@ .sum-msg-thread-empty-hint { margin: 0; - max-width: 15rem; + max-width: 14rem; + font-size: var(--sum-font-size-sm); + line-height: 1.45; +} + +/* Active conversation: header + scrollable thread + pinned composer */ +.sum-msg-chat { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sum-msg-chat-head { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid var(--sum-line); + background: var(--sum-surface); +} + +.sum-msg-chat-head .sum-msg-avatar { + width: 1.65rem; + height: 1.65rem; + font-size: 0.6rem; +} + +.sum-msg-chat-title { + margin: 0; + font-size: var(--sum-font-size-sm); + font-weight: var(--sum-font-weight-semibold); + color: var(--sum-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sum-msg-thread { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 0.65rem 0.55rem; + background: color-mix(in srgb, var(--sum-surface-alt) 55%, var(--sum-surface)); +} + +.sum-msg-bubbles { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.sum-msg-thread-center { + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0.75rem; +} + +.sum-msg-bubble { + display: flex; + flex-direction: column; + max-width: 92%; + align-self: flex-start; +} + +.sum-msg-bubble--out { + align-self: flex-end; +} + +.sum-msg-bubble-inner { + padding: 0.45rem 0.6rem; + border-radius: 1rem; + border-bottom-left-radius: 0.25rem; + background: var(--sum-surface); + border: 1px solid var(--sum-line); + box-shadow: var(--sum-shadow-sm); +} + +.sum-msg-bubble--out .sum-msg-bubble-inner { + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 0.25rem; + background: var(--sum-header-tint-12); + border-color: color-mix(in srgb, var(--sum-header) 18%, var(--sum-line)); +} + +.sum-msg-bubble--pending .sum-msg-bubble-inner { + opacity: 0.72; +} + +.sum-msg-bubble-meta { + margin: 0 0 0.2rem; + padding: 0 0.25rem; + font-size: 0.625rem; + color: var(--sum-muted); + text-align: right; +} + +.sum-msg-bubble--out .sum-msg-bubble-meta { + text-align: right; +} + +.sum-msg-bubble:not(.sum-msg-bubble--out) .sum-msg-bubble-meta { + text-align: left; +} + +.sum-msg-body { font-size: var(--sum-font-size-sm); line-height: 1.45; + color: var(--sum-muted-ink); + white-space: pre-wrap; + word-break: break-word; +} + +.sum-msg-bubble-extra { + margin: 0.25rem 0 0; + padding: 0 0.15rem; + font-size: 0.625rem; } .sum-msg-composer { flex-shrink: 0; - margin-top: auto; - padding-top: 0.5rem; + padding: 0.45rem 0.55rem 0.5rem; border-top: 1px solid var(--sum-line); - background: linear-gradient(180deg, transparent 0%, var(--sum-surface-alt) 35%); + background: var(--sum-surface); +} + +.sum-msg-composer--stub { + opacity: 0.85; } -.sum-msg-form { +.sum-msg-composer-bar { display: flex; - flex-direction: column; - gap: 0.45rem; + align-items: flex-end; + gap: 0.35rem; +} + +.sum-msg-composer-attach { + flex-shrink: 0; + margin: 0; +} + +.sum-msg-composer-attach .sum-btn { + padding: 0.35rem 0.5rem; + font-size: 0.6875rem; } .sum-msg-input { - width: 100%; + flex: 1; + min-width: 0; box-sizing: border-box; - min-height: 4.5rem; - resize: vertical; - padding: 0.55rem 0.65rem; + min-height: 2.25rem; + max-height: 6.5rem; + resize: none; + padding: 0.45rem 0.55rem; font-family: var(--sum-font-sans); font-size: var(--sum-font-size-sm); - line-height: 1.45; + line-height: 1.4; color: var(--sum-ink); - background: var(--sum-surface); - border: 1px solid var(--sum-line-strong); - border-radius: var(--sum-radius-sm); + background: var(--sum-surface-alt); + border: 1px solid var(--sum-line); + border-radius: 1rem; transition: border-color var(--sum-dur) var(--sum-ease), box-shadow var(--sum-dur) var(--sum-ease); } @@ -150,34 +446,22 @@ color: var(--sum-muted); } -.sum-msg-input:hover { - border-color: color-mix(in srgb, var(--sum-header) 25%, var(--sum-line-strong)); +.sum-msg-input:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--sum-header) 20%, var(--sum-line)); } .sum-msg-input:focus { outline: none; - border-color: color-mix(in srgb, var(--sum-header) 45%, var(--sum-line-strong)); + border-color: color-mix(in srgb, var(--sum-header) 40%, var(--sum-line)); box-shadow: var(--sum-focus-ring); -} - -.sum-msg-form-actions { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 0.35rem; -} - -.sum-msg-form-hint { - margin: 0; - font-size: 0.6875rem; - color: var(--sum-muted); + background: var(--sum-surface); } .sum-msg-send { - margin-left: auto; - padding: 0.4rem 1rem; - border-radius: var(--sum-radius-sm); + flex-shrink: 0; + padding: 0.4rem 0.85rem; + min-height: 2.25rem; + border-radius: 999px; border: 1px solid var(--sum-header-dark); background: var(--sum-header); color: var(--sum-topbar-fg); @@ -187,7 +471,7 @@ transition: background var(--sum-dur) var(--sum-ease); } -.sum-msg-send:hover { +.sum-msg-send:hover:not(:disabled) { background: var(--sum-header-dark); } @@ -196,83 +480,71 @@ box-shadow: var(--sum-focus-ring); } -.sum-msg-shell--dock { - height: 100%; - min-height: 12rem; +.sum-msg-send:disabled { + opacity: 0.55; + cursor: not-allowed; } -.sum-msg-layout { - display: flex; - flex-direction: column; - gap: 0.5rem; - height: 100%; - min-height: 0; +.sum-msg-form-hint { + margin: 0.3rem 0 0; + font-size: 0.625rem; + color: var(--sum-muted); + text-align: center; } -.sum-msg-sidebar { - flex-shrink: 0; +.sum-msg-skeleton { + padding: 0.75rem; display: flex; flex-direction: column; - gap: 0.35rem; -} - -.sum-msg-search { - width: 100%; - box-sizing: border-box; - padding: 0.4rem 0.55rem; - font-size: var(--sum-font-size-sm); - border: 1px solid var(--sum-line); - border-radius: var(--sum-radius-sm); -} - -.sum-msg-peer-list { - list-style: none; - margin: 0; - padding: 0; - max-height: 8rem; - overflow: auto; - border: 1px solid var(--sum-line); - border-radius: var(--sum-radius-sm); + gap: 0.45rem; } -.sum-msg-peer-btn { - width: 100%; - text-align: left; - padding: 0.45rem 0.55rem; - border: none; - border-bottom: 1px solid var(--sum-line); - background: var(--sum-surface); - cursor: pointer; - display: flex; - flex-direction: column; - gap: 0.15rem; +.sum-msg-skeleton-line { + height: 0.65rem; + border-radius: 4px; + background: linear-gradient(90deg, var(--sum-line) 25%, var(--sum-row-hover-bg) 50%, var(--sum-line) 75%); + background-size: 200% 100%; + animation: sum-msg-shimmer 1.2s ease-in-out infinite; } -.sum-msg-peer-btn.is-active, -.sum-msg-peer-btn:hover { - background: var(--sum-row-hover-bg); +.sum-msg-skeleton-line--short { + width: 55%; } -.sum-msg-peer-btn:last-child { - border-bottom: none; +@keyframes sum-msg-shimmer { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } } -.sum-msg-main { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; +/* 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-thread-head { +.sum-msg-author { font-size: var(--sum-font-size-sm); font-weight: var(--sum-font-weight-semibold); - padding-bottom: 0.35rem; - border-bottom: 1px solid var(--sum-line); - margin-bottom: 0.35rem; + color: var(--sum-ink); +} + +.sum-msg-time { + font-size: 0.6875rem; + 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/engine/assets/css/sumeru-shell.css b/core/engine/assets/css/sumeru-shell.css index 99184b0d..594f3c73 100644 --- a/core/engine/assets/css/sumeru-shell.css +++ b/core/engine/assets/css/sumeru-shell.css @@ -886,6 +886,18 @@ background: rgba(255, 255, 255, 0.6); } +.sum-activity-tab-badge { + margin-left: 0.35rem; + min-width: 1rem; + padding: 0 0.3rem; + font-size: 0.65rem; + font-weight: 700; + line-height: 1rem; + border-radius: 999px; + background: var(--sum-danger, #dc2626); + color: #fff; +} + .sum-activity-tab.is-active { color: var(--sum-header); background: var(--sum-surface); @@ -925,6 +937,8 @@ .sum-activity-pane--messages { display: flex; flex-direction: column; + overflow: hidden; + padding: 0.45rem 0.5rem 0.5rem; } .sum-activity-body { diff --git a/core/engine/assets/css/sumeru-theme.css b/core/engine/assets/css/sumeru-theme.css index cfcfedf2..7ca628c7 100644 --- a/core/engine/assets/css/sumeru-theme.css +++ b/core/engine/assets/css/sumeru-theme.css @@ -97,7 +97,7 @@ --sum-header-height: 52px; --sum-breadcrumb-min-height: 48px; --sum-activity-width: 300px; - --sum-activity-width-min: 200px; + --sum-activity-width-min: 280px; --sum-activity-width-max: 520px; /* —— Radii —— */ diff --git a/core/swc/src/shell/activity-context.ts b/core/swc/src/shell/activity-context.ts index 3cb03309..d48a0429 100644 --- a/core/swc/src/shell/activity-context.ts +++ b/core/swc/src/shell/activity-context.ts @@ -26,15 +26,3 @@ export function emptyActivityContext(): ActivityContextPayload { recordLogEligible: false, }; } - -export function messagesEmptyStateHTML(): string { - return `
    - -

    Messages

    -

    Search for an internal user to start a direct conversation.

    -
    `; -} diff --git a/core/swc/src/shell/activity-im-banner.ts b/core/swc/src/shell/activity-im-banner.ts new file mode 100644 index 00000000..53325f80 --- /dev/null +++ b/core/swc/src/shell/activity-im-banner.ts @@ -0,0 +1,42 @@ +import type { SwcBootstrap } from "../types/bootstrap.js"; + +const BANNER_ID = "sum-im-disabled-banner"; + +/** Top-of-workspace banner when internal chat is off for the active company (P1). */ +export function initInternalMessagesBanner(boot: SwcBootstrap): void { + if (boot.internalMessagesEnabled !== false) return; + const content = document.querySelector(".sum-content"); + if (!content || document.getElementById(BANNER_ID)) return; + + const bar = document.createElement("div"); + bar.id = BANNER_ID; + bar.className = "sum-flash-prem sum-flash-prem--warning"; + bar.setAttribute("role", "status"); + + const body = document.createElement("p"); + body.className = "sum-flash-body"; + body.textContent = + "Internal messages are turned off for this company. Enable Internal chat on the company record to use the Messages panel."; + + const actions = document.createElement("p"); + actions.className = "sum-flash-body"; + if (boot.companyFormHref) { + const link = document.createElement("a"); + link.href = boot.companyFormHref; + link.className = "sum-chatter-attachment-link"; + link.textContent = "Open company settings"; + actions.appendChild(link); + } + + const dismiss = document.createElement("button"); + dismiss.type = "button"; + dismiss.className = "sum-flash-dismiss"; + dismiss.setAttribute("aria-label", "Dismiss"); + dismiss.textContent = "×"; + dismiss.addEventListener("click", () => bar.remove()); + + bar.appendChild(dismiss); + bar.appendChild(body); + if (boot.companyFormHref) bar.appendChild(actions); + content.prepend(bar); +} diff --git a/core/swc/src/shell/activity-log.ts b/core/swc/src/shell/activity-log.ts index 7857de4d..5ce60114 100644 --- a/core/swc/src/shell/activity-log.ts +++ b/core/swc/src/shell/activity-log.ts @@ -11,6 +11,8 @@ interface ActivityLogItem { interface ActivityLogResponse { items: ActivityLogItem[]; hasLog?: boolean; + hasMore?: boolean; + offset?: number; } export function initActivityLog(env: SwcEnv): void { @@ -18,15 +20,37 @@ export function initActivityLog(env: SwcEnv): void { if (!host) return; let current: ActivityContextPayload | null = null; + let logOffset = 0; + let logHasMore = false; + let cachedItems: ActivityLogItem[] = []; const clearLogPane = (): void => { host.innerHTML = ""; host.hidden = true; setActivityLogTabVisible(false); + logOffset = 0; + logHasMore = false; + cachedItems = []; }; - const renderItems = (items: ActivityLogItem[]): void => { - const rows = items + const renderListHint = (): void => { + host.innerHTML = `

    Open a saved record (form view) to view its change history here.

    `; + host.hidden = false; + }; + + const renderEmpty = (): void => { + host.innerHTML = `

    No changes logged yet.

    `; + host.hidden = false; + }; + + const renderItems = (items: ActivityLogItem[], append: boolean): void => { + if (items.length === 0 && !append) { + renderEmpty(); + return; + } + const all = append ? [...cachedItems, ...items] : items; + cachedItems = all; + const rows = all .map( (item) => `
    @@ -34,34 +58,49 @@ export function initActivityLog(env: SwcEnv): void {
    `, ) .join(""); - host.innerHTML = `
    ${rows}
    `; + const moreBtn = logHasMore + ? `` + : ""; + host.innerHTML = `
    ${rows}${moreBtn}
    `; + host.hidden = false; + host.querySelector(".sum-activity-log-more")?.addEventListener("click", () => { + if (current) void loadLog(current, logOffset, true); + }); }; - const loadLog = async (ctx: ActivityContextPayload): Promise => { - if (!ctx.recordLogEligible || ctx.recordId <= 0 || !ctx.model) { + const loadLog = async (ctx: ActivityContextPayload, offset = 0, append = false): Promise => { + if (!ctx.recordLogEligible || !ctx.model) { clearLogPane(); return; } + setActivityLogTabVisible(true); + if (ctx.recordId <= 0) { + renderListHint(); + return; + } try { const base = env.bootstrap.swcApiBase || SWC_API_BASE; const data = await env.services.http.getJSON( - `${base}/activity-log?model=${encodeURIComponent(ctx.model)}&id=${ctx.recordId}`, + `${base}/activity-log?model=${encodeURIComponent(ctx.model)}&id=${ctx.recordId}&offset=${offset}`, ); + logOffset = (data.offset ?? offset) + (data.items?.length ?? 0); + logHasMore = data.hasMore === true; const items = data.items ?? []; - const hasLog = data.hasLog === true || items.length > 0; - if (!hasLog) { - clearLogPane(); - return; + if (data.hasLog === true || items.length > 0 || append) { + renderItems(items, append); + } else { + renderEmpty(); } - setActivityLogTabVisible(true); - renderItems(items); } catch { - clearLogPane(); + renderEmpty(); } }; env.services.bus.subscribe(ACTIVITY_CONTEXT, (payload) => { current = payload as ActivityContextPayload; + logOffset = 0; + logHasMore = false; + cachedItems = []; void loadLog(current); }); @@ -70,6 +109,9 @@ export function initActivityLog(env: SwcEnv): void { if (!current?.recordLogEligible) return; const rid = msg.id ?? msg.recordId; if (msg.model !== current.model || rid !== current.recordId) return; + logOffset = 0; + logHasMore = false; + cachedItems = []; void loadLog(current); }); diff --git a/core/swc/src/shell/activity-panel.ts b/core/swc/src/shell/activity-panel.ts index 643500cf..aca74663 100644 --- a/core/swc/src/shell/activity-panel.ts +++ b/core/swc/src/shell/activity-panel.ts @@ -55,6 +55,24 @@ function initActivityTabs(): void { }); } +/** Unread badge on the Messages activity tab (P3). */ +export function setMessagesUnreadBadge(count: number): void { + const tab = document.querySelector('[data-sum-activity-tab="messages"]'); + if (!tab) return; + let badge = tab.querySelector(".sum-activity-tab-badge"); + if (count <= 0) { + badge?.remove(); + return; + } + if (!badge) { + badge = document.createElement("span"); + badge.className = "sum-activity-tab-badge"; + badge.setAttribute("aria-hidden", "true"); + tab.appendChild(badge); + } + badge.textContent = count > 99 ? "99+" : String(count); +} + /** Show or hide the Log tab; switches to Messages when Log is hidden while active. */ export function setActivityLogTabVisible(visible: boolean): void { const logTab = document.querySelector('[data-sum-activity-tab="log"]'); @@ -95,7 +113,7 @@ function initActivityResizer(shell: HTMLElement): void { if (!dragging) return; const delta = startX - ev.clientX; let width = startW + delta; - width = Math.min(520, Math.max(200, width)); + width = Math.min(520, Math.max(280, width)); applyActivityWidth(width); }); diff --git a/core/swc/src/shell/app-launcher.ts b/core/swc/src/shell/app-launcher.ts index 2b223f3e..0ff4bb9e 100644 --- a/core/swc/src/shell/app-launcher.ts +++ b/core/swc/src/shell/app-launcher.ts @@ -1,6 +1,9 @@ import type { ActionService } from "../services/action.js"; import type { CommandService } from "../services/command.js"; import type { SwcBootstrap, SwcBootstrapApp } from "../types/bootstrap.js"; +import { fuzzyScore } from "../util/fuzzy.js"; + +export { fuzzyScore } from "../util/fuzzy.js"; export interface LauncherItem { kind: "app" | "menu" | "command"; @@ -58,22 +61,6 @@ function appToLauncherItem(app: SwcBootstrapApp): LauncherItem { }; } -export function fuzzyScore(query: string, text: string): number { - const q = query.trim().toLowerCase(); - const t = text.trim().toLowerCase(); - if (!q) return 1; - if (!t) return 0; - if (t === q) return 100; - if (t.startsWith(q)) return 80; - if (t.includes(q)) return 60; - - let qi = 0; - for (let i = 0; i < t.length && qi < q.length; i++) { - if (t[i] === q[qi]) qi++; - } - return qi === q.length ? 40 : 0; -} - export function scoreLauncherItem(query: string, item: LauncherItem): number { const q = query.trim(); if (!q) return 1; diff --git a/core/swc/src/shell/internal-messages-panel.ts b/core/swc/src/shell/internal-messages-panel.ts index bddec21e..ff941b22 100644 --- a/core/swc/src/shell/internal-messages-panel.ts +++ b/core/swc/src/shell/internal-messages-panel.ts @@ -1,7 +1,14 @@ import { SwcComponent } from "../runtime/component.js"; import { html } from "../template/html.js"; -import { SWC_API_BASE } from "../constants/routes.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 { setMessagesUnreadBadge } from "./activity-panel.js"; +import { rankByFuzzy } from "../util/fuzzy.js"; +import { formatRelativeTime } from "../util/format-relative-time.js"; +import { clearImDraft, readImDraft, writeImDraft } from "../util/im-draft.js"; +import { userInitials } from "../util/user-initials.js"; interface DirectUser { id: number; @@ -14,6 +21,7 @@ interface DirectConversation { name: string; preview?: string; lastMessage?: string; + unreadCount?: number; } interface DirectAttachment { @@ -27,77 +35,255 @@ interface DirectMessage { body: string; createDate: string; outgoing: boolean; + pending?: boolean; + resModel?: string; + resId?: number; attachments?: DirectAttachment[]; } +interface DirectStatus { + enabled?: boolean; + unread?: number; + companyFormHref?: string; +} + +const PICKER_LIMIT = 15; +const DRAFT_DEBOUNCE_MS = 400; + export class InternalMessagesPanel extends SwcComponent { private search = ""; - private searchResults: DirectUser[] = []; + private serverSearchResults: DirectUser[] = []; + private userDirectory: DirectUser[] = []; private conversations: DirectConversation[] = []; private peerId = 0; private peerName = ""; private thread: DirectMessage[] = []; private draft = ""; private loading = false; + private bootstrapLoading = true; private posting = false; - private enabled = true; + private imDisabled = false; private disabledReason = ""; + private companyFormHref = ""; + private banner = ""; + private linkModel = ""; + private linkRecordId = 0; + private pickerIndex = 0; + private pickerOpen = false; + private scrollThreadEnd = false; + private focusComposerNext = false; + private draftTimer: ReturnType | null = null; private searchTimer: ReturnType | null = null; + private unsubActivity: (() => void) | null = null; + + override onMount(): void { + this.companyFormHref = this.env.bootstrap.companyFormHref ?? ""; + if (this.env.bootstrap.internalMessagesEnabled === false) { + this.imDisabled = true; + this.disabledReason = "Internal messages are disabled for this company."; + } + this.unsubActivity = this.env.services.bus.subscribe(ACTIVITY_CONTEXT, (payload) => { + const ctx = payload as ActivityContextPayload; + if (ctx.recordId > 0 && ctx.model) { + this.linkModel = ctx.model; + this.linkRecordId = ctx.recordId; + } else { + this.linkModel = ""; + this.linkRecordId = 0; + } + }); + void this.bootstrapPanel(); + } - override setup(): void { - void this.refreshConversations(); + override onWillUnmount(): void { + this.unsubActivity?.(); + this.unsubActivity = null; + } + + override afterPatch(): void { + if (this.scrollThreadEnd) { + this.scrollThreadEnd = false; + const el = this.rootElement?.querySelector(".sum-msg-thread"); + if (el) el.scrollTop = el.scrollHeight; + } + if (this.focusComposerNext) { + this.focusComposerNext = false; + const ta = this.rootElement?.querySelector(".sum-msg-input:not([disabled])"); + ta?.focus(); + } } private apiBase(): string { return this.env.bootstrap.swcApiBase || SWC_API_BASE; } + private async bootstrapPanel(): Promise { + this.bootstrapLoading = true; + this.rerender(); + await this.loadStatus(); + if (!this.imDisabled) { + await Promise.all([this.refreshConversations(), this.loadDirectoryUsers()]); + } + this.bootstrapLoading = false; + this.rerender(); + } + + private async loadStatus(): Promise { + try { + const data = await this.env.services.http.getJSON(`${this.apiBase()}/direct/status`); + if (data.enabled === false) { + this.imDisabled = true; + this.disabledReason = "Internal messages are disabled for this company."; + } else { + this.imDisabled = false; + } + if (data.companyFormHref) this.companyFormHref = data.companyFormHref; + setMessagesUnreadBadge(data.unread ?? 0); + } catch { + /* keep bootstrap defaults */ + } + } + + private rankedPickerUsers(): DirectUser[] { + const q = this.search.trim(); + const ranked = rankByFuzzy(q, this.userDirectory, (u) => [u.name, u.login]); + let list = ranked.slice(0, PICKER_LIMIT).map((r) => r.item); + if (q.length >= 2 && list.length < 3) { + const seen = new Set(list.map((u) => u.id)); + for (const u of this.serverSearchResults) { + if (seen.has(u.id)) continue; + list.push(u); + seen.add(u.id); + if (list.length >= PICKER_LIMIT) break; + } + } + return list; + } + private async refreshConversations(): Promise { try { const data = await this.env.services.http.getJSON<{ conversations: DirectConversation[] }>( `${this.apiBase()}/direct/conversations`, ); this.conversations = data.conversations ?? []; - this.enabled = true; - this.rerender(); - } catch { - this.enabled = false; - this.disabledReason = "Internal messages are unavailable or disabled for this company."; - this.rerender(); + this.banner = ""; + } catch (err) { + this.conversations = []; + if (err instanceof SwcError && err.message.includes("403")) { + this.banner = + "Recent chats are unavailable (permissions or IM not fully set up). You can still search colleagues below."; + } else { + this.banner = "Recent chats could not be loaded. Search for a colleague to start messaging."; + } } } - private async runSearch(): Promise { - const q = this.search.trim(); - if (q.length < 1) { - this.searchResults = []; - this.rerender(); - return; + private async loadDirectoryUsers(): Promise { + try { + const data = await this.env.services.http.getJSON<{ users: DirectUser[] }>( + `${this.apiBase()}/direct/users`, + ); + this.userDirectory = data.users ?? []; + } catch { + this.userDirectory = []; } + } + + private async fetchServerSearch(q: string): Promise { try { const data = await this.env.services.http.getJSON<{ users: DirectUser[] }>( `${this.apiBase()}/direct/users?q=${encodeURIComponent(q)}`, ); - this.searchResults = data.users ?? []; + this.serverSearchResults = data.users ?? []; } catch { - this.searchResults = []; + this.serverSearchResults = []; } this.rerender(); } private scheduleSearch(): void { if (this.searchTimer) clearTimeout(this.searchTimer); - this.searchTimer = setTimeout(() => void this.runSearch(), 250); + this.searchTimer = setTimeout(() => { + const q = this.search.trim(); + this.pickerIndex = 0; + this.pickerOpen = q.length > 0; + if (q.length >= 2 && this.rankedPickerUsers().length < 3) { + void this.fetchServerSearch(q); + } else { + this.serverSearchResults = []; + this.rerender(); + } + }, 150); + } + + private scheduleDraftSave(): void { + if (this.draftTimer) clearTimeout(this.draftTimer); + this.draftTimer = setTimeout(() => writeImDraft(this.peerId, this.draft), DRAFT_DEBOUNCE_MS); + } + + private onSearchKeydown(event: Event): void { + const ev = event as KeyboardEvent; + const q = this.search.trim(); + if (!q) return; + const picks = this.rankedPickerUsers(); + if (picks.length === 0) return; + + if (ev.key === "ArrowDown") { + ev.preventDefault(); + this.pickerIndex = Math.min(this.pickerIndex + 1, picks.length - 1); + this.rerender(); + } else if (ev.key === "ArrowUp") { + ev.preventDefault(); + this.pickerIndex = Math.max(this.pickerIndex - 1, 0); + this.rerender(); + } else if (ev.key === "Enter") { + ev.preventDefault(); + const u = picks[this.pickerIndex] ?? picks[0]; + if (u) void this.openPeer(u.id, u.name || u.login); + } else if (ev.key === "Escape") { + this.search = ""; + this.pickerOpen = false; + this.serverSearchResults = []; + this.rerender(); + } + } + + private onComposerKeydown(event: Event): void { + const ev = event as KeyboardEvent; + if (ev.key !== "Enter") return; + if (ev.shiftKey) return; + ev.preventDefault(); + void this.postMessage(); } private async openPeer(userId: number, name: string): Promise { + if (this.peerId > 0) writeImDraft(this.peerId, this.draft); this.peerId = userId; this.peerName = name; this.search = ""; - this.searchResults = []; + this.serverSearchResults = []; + this.pickerOpen = false; + this.draft = readImDraft(userId); + this.focusComposerNext = true; await this.loadThread(); } + private postFields(body: string): Record { + const fields: Record = { + recipient_id: String(this.peerId), + body, + }; + if (this.linkModel && this.linkRecordId > 0) { + fields.res_model = this.linkModel; + fields.res_id = String(this.linkRecordId); + } + return fields; + } + + private recordLinkHref(model: string, id: number): string { + return `/web?model=${encodeURIComponent(model)}&view_type=${VIEW_FORM}&id=${id}`; + } + private async loadThread(): Promise { if (this.peerId <= 0) return; this.loading = true; @@ -107,6 +293,9 @@ export class InternalMessagesPanel extends SwcComponent { `${this.apiBase()}/direct/thread?userId=${this.peerId}`, ); this.thread = data.messages ?? []; + this.scrollThreadEnd = true; + await this.loadStatus(); + await this.refreshConversations(); } finally { this.loading = false; this.rerender(); @@ -115,17 +304,29 @@ export class InternalMessagesPanel extends SwcComponent { private async postMessage(): Promise { const body = this.draft.trim(); - if (!body || this.peerId <= 0) return; + if (!body || this.peerId <= 0 || this.posting) return; + const tempId = -Date.now(); + const optimistic: DirectMessage = { + id: tempId, + body, + createDate: new Date().toISOString(), + outgoing: true, + pending: true, + }; + this.thread = [...this.thread, optimistic]; + this.draft = ""; + clearImDraft(this.peerId); this.posting = true; + this.scrollThreadEnd = true; this.rerender(); try { - await this.env.services.http.postForm(`${this.apiBase()}/direct/post`, { - recipient_id: String(this.peerId), - body, - }); - this.draft = ""; + await this.env.services.http.postForm(`${this.apiBase()}/direct/post`, this.postFields(body)); await this.loadThread(); await this.refreshConversations(); + } catch { + this.thread = this.thread.filter((m) => m.id !== tempId); + this.draft = body; + this.rerender(); } finally { this.posting = false; this.rerender(); @@ -136,20 +337,18 @@ export class InternalMessagesPanel extends SwcComponent { if (this.peerId <= 0) return; let messageId = 0; if (this.draft.trim()) { - const res = await this.env.services.http.postForm(`${this.apiBase()}/direct/post`, { - recipient_id: String(this.peerId), - body: this.draft.trim(), - }); + const res = await this.env.services.http.postForm( + `${this.apiBase()}/direct/post`, + this.postFields(this.draft.trim()), + ); const parsed = (await res.json()) as { id?: number }; messageId = parsed.id ?? 0; this.draft = ""; + clearImDraft(this.peerId); } else if (this.thread.length > 0) { messageId = this.thread[this.thread.length - 1].id; } else { - const res = await this.env.services.http.postForm(`${this.apiBase()}/direct/post`, { - recipient_id: String(this.peerId), - body: " ", - }); + const res = await this.env.services.http.postForm(`${this.apiBase()}/direct/post`, this.postFields(" ")); const parsed = (await res.json()) as { id?: number }; messageId = parsed.id ?? 0; } @@ -157,125 +356,258 @@ export class InternalMessagesPanel extends SwcComponent { const form = new FormData(); form.set("message_id", String(messageId)); form.set("file", file); - await this.env.services.http.postMultipart("/web/swc/direct/upload", form); + await this.env.services.http.postMultipart(`${this.apiBase()}/direct/upload`, form); await this.loadThread(); await this.refreshConversations(); + this.scrollThreadEnd = true; this.rerender(); } + private colleagueDirectory(): DirectUser[] { + const convIds = new Set(this.conversations.map((c) => c.userId)); + return this.userDirectory.filter((u) => !convIds.has(u.id)); + } + + private userRow( + u: DirectUser, + active: boolean, + sub: string, + unread = 0, + pickerIdx?: number, + ): ReturnType { + const label = u.name || u.login; + const initials = userInitials(u.name, u.login); + return html`
  • + +
  • `; + } + + private composerHint(): ReturnType { + if (this.linkModel && this.linkRecordId > 0) { + return html`

    Linked to open record · Enter to send

    `; + } + return html`

    Enter to send · Shift+Enter for new line

    `; + } + + private composerStub(): ReturnType { + return html`
    +
    + Attach + + +
    + ${this.composerHint()} +
    `; + } + + private activeComposer(): ReturnType { + return html`
    +
    + + + +
    + ${this.composerHint()} +
    `; + } + + private messageBubble(m: DirectMessage): ReturnType { + const when = formatRelativeTime(m.createDate) || m.createDate; + return html`
  • +

    ${when}${m.pending ? " · Sending" : ""}

    +
    +
    ${m.body}
    +
    + ${m.resModel && (m.resId ?? 0) > 0 + ? html`

    + Related record +

    ` + : ""} + ${(m.attachments ?? []).length > 0 + ? html`
      + ${(m.attachments ?? []).map( + (a) => html`
    • ${a.name}
    • `, + )} +
    ` + : ""} +
  • `; + } + + private activeChat(): ReturnType { + const initials = userInitials(this.peerName, ""); + return html`
    +
    + +

    ${this.peerName}

    +
    +
    + ${this.loading + ? html`
    ` + : this.thread.length === 0 + ? html`
    +

    No messages yet. Say hello.

    +
    ` + : html`
      ${this.thread.map((m) => this.messageBubble(m))}
    `} +
    + ${this.activeComposer()} +
    `; + } + override template() { - if (!this.enabled) { + if (this.imDisabled) { return html`

    Messages

    ${this.disabledReason}

    + ${this.companyFormHref + ? html`

    + Open company settings + and enable Internal chat. +

    ` + : ""}
    `; } - const listItems = - this.searchResults.length > 0 - ? this.searchResults.map( - (u) => html`
  • - -
  • `, - ) - : this.conversations.map( - (c) => html`
  • - -
  • `, - ); + if (this.bootstrapLoading) { + return html`
    +
    +
    +
    +
    +
    +
    `; + } + + const q = this.search.trim(); + const pickerUsers = q ? this.rankedPickerUsers() : []; + const showPicker = this.pickerOpen && q.length > 0; + + const sidebarBlocks: ReturnType[] = []; + + if (showPicker) { + sidebarBlocks.push( + html`
      + ${pickerUsers.length === 0 + ? html`
    • No matches for “${q}”
    • ` + : pickerUsers.map((u, i) => + this.userRow(u, this.peerId === u.id, u.login, 0, i), + )} +
    `, + ); + } else { + if (this.conversations.length > 0) { + sidebarBlocks.push(html``); + sidebarBlocks.push( + html`
      + ${this.conversations.map((c) => { + const when = formatRelativeTime(c.lastMessage ?? ""); + const sub = [c.preview, when].filter(Boolean).join(" · "); + return this.userRow( + { id: c.userId, name: c.name, login: "" }, + this.peerId === c.userId, + sub, + c.unreadCount ?? 0, + ); + })} +
    `, + ); + } + const colleagues = this.colleagueDirectory(); + if (colleagues.length > 0) { + sidebarBlocks.push(html``); + sidebarBlocks.push( + html`
      + ${colleagues.map((u) => this.userRow(u, this.peerId === u.id, u.login))} +
    `, + ); + } + if (this.conversations.length === 0 && colleagues.length === 0) { + sidebarBlocks.push(html`

    No internal users found.

    `); + } + } return html`
    + ${this.banner ? html`

    ${this.banner}

    ` : ""}
    { this.search = inputValueFromEvent(event); + this.pickerOpen = this.search.trim().length > 0; this.scheduleSearch(); this.rerender(); }} + @keydown=${(event: Event) => this.onSearchKeydown(event)} /> -
      ${listItems}
    +
    ${sidebarBlocks}
    ${this.peerId <= 0 - ? html`
    -

    Direct messages

    -

    Search for a colleague or pick a recent conversation.

    -
    ` - : html`
    ${this.peerName}
    -
    - ${this.loading - ? html`

    Loading…

    ` - : this.thread.length === 0 - ? html`

    No messages yet. Say hello.

    ` - : this.thread.map( - (m) => html`
    -
    - ${m.createDate} -
    -
    ${m.body}
    - ${(m.attachments ?? []).length > 0 - ? html`
      - ${(m.attachments ?? []).map( - (a) => html`
    • ${a.name}
    • `, - )} -
    ` - : ""} -
    `, - )} + ? html`
    +

    Your messages

    +

    Choose someone from the list or search to start a conversation.

    -
    -
    - -
    - - -
    -

    Internal users only · not shown on record Log

    -
    -
    `} + ${this.composerStub()}` + : this.activeChat()}
    `; diff --git a/core/swc/src/shell/messages-unread.ts b/core/swc/src/shell/messages-unread.ts new file mode 100644 index 00000000..ae95ee68 --- /dev/null +++ b/core/swc/src/shell/messages-unread.ts @@ -0,0 +1,28 @@ +import type { HttpService } from "../services/http.js"; +import { SWC_API_BASE } from "../constants/routes.js"; +import { setMessagesUnreadBadge } from "./activity-panel.js"; + +interface DirectStatusPayload { + enabled?: boolean; + unread?: number; +} + +export function initMessagesUnreadPoll(http: HttpService, apiBase?: string): void { + const base = apiBase || SWC_API_BASE; + + const refresh = async (): Promise => { + try { + const data = await http.getJSON(`${base}/direct/status`); + if (data.enabled === false) { + setMessagesUnreadBadge(0); + return; + } + setMessagesUnreadBadge(data.unread ?? 0); + } catch { + setMessagesUnreadBadge(0); + } + }; + + void refresh(); + window.setInterval(() => void refresh(), 60_000); +} diff --git a/core/swc/src/shell/shell-chrome.ts b/core/swc/src/shell/shell-chrome.ts index 3861143d..07292abe 100644 --- a/core/swc/src/shell/shell-chrome.ts +++ b/core/swc/src/shell/shell-chrome.ts @@ -5,6 +5,8 @@ import type { SwcEnv } from "../runtime/env.js"; import { initActivityPanel } from "./activity-panel.js"; import { initActivityMessages } from "./activity-messages.js"; import { initActivityLog } from "./activity-log.js"; +import { initInternalMessagesBanner } from "./activity-im-banner.js"; +import { initMessagesUnreadPoll } from "./messages-unread.js"; import { ACTIVITY_CONTEXT, emptyActivityContext } from "./activity-context.js"; import { initHomeDashboard } from "./home-dashboard.js"; import { initPinnedApps } from "./pinned-apps.js"; @@ -23,11 +25,14 @@ export function initShellChrome(boot: SwcBootstrap, http: HttpService, bus?: Bus initViewTabNavigation(); initBreadcrumbNavigation(); + initInternalMessagesBanner(boot); + if (boot.activityEnabled) { initActivityPanel(shell); if (env) { initActivityMessages(env); initActivityLog(env); + initMessagesUnreadPoll(http, boot.swcApiBase); env.services.bus.emit(ACTIVITY_CONTEXT, emptyActivityContext()); } } diff --git a/core/swc/src/types/bootstrap.ts b/core/swc/src/types/bootstrap.ts index 0018af2a..d22a3317 100644 --- a/core/swc/src/types/bootstrap.ts +++ b/core/swc/src/types/bootstrap.ts @@ -56,6 +56,8 @@ export interface SwcBootstrap { appsNavAllowed: boolean; settingsNavAllowed: boolean; activityEnabled: boolean; + internalMessagesEnabled?: boolean; + companyFormHref?: string; busEnabled?: boolean; docsUrl: string; profileUrl: string; diff --git a/core/swc/src/util/format-relative-time.ts b/core/swc/src/util/format-relative-time.ts new file mode 100644 index 00000000..04c34536 --- /dev/null +++ b/core/swc/src/util/format-relative-time.ts @@ -0,0 +1,17 @@ +/** Short relative label for message timestamps (ISO or RFC3339). */ +export function formatRelativeTime(iso: string): string { + const raw = iso.trim(); + if (!raw) return ""; + const t = Date.parse(raw); + if (Number.isNaN(t)) return raw; + const diffSec = Math.round((Date.now() - t) / 1000); + if (diffSec < 45) return "Just now"; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d ago`; + try { + return new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } catch { + return raw; + } +} diff --git a/core/swc/src/util/fuzzy.ts b/core/swc/src/util/fuzzy.ts new file mode 100644 index 00000000..503e86e5 --- /dev/null +++ b/core/swc/src/util/fuzzy.ts @@ -0,0 +1,35 @@ +/** Subsequence / prefix fuzzy match (shared by app launcher and IM user search). */ + +export function fuzzyScore(query: string, text: string): number { + const q = query.trim().toLowerCase(); + const t = text.trim().toLowerCase(); + if (!q) return 1; + if (!t) return 0; + if (t === q) return 100; + if (t.startsWith(q)) return 80; + if (t.includes(q)) return 60; + + let qi = 0; + for (let i = 0; i < t.length && qi < q.length; i++) { + if (t[i] === q[qi]) qi++; + } + return qi === q.length ? 40 : 0; +} + +export interface FuzzyRanked { + item: T; + score: number; +} + +export function rankByFuzzy(query: string, items: T[], fields: (item: T) => string[]): FuzzyRanked[] { + const q = query.trim(); + if (!q) return items.map((item) => ({ item, score: 1 })); + return items + .map((item) => { + const vals = fields(item); + const score = Math.max(0, ...vals.map((v) => fuzzyScore(q, v))); + return { item, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score); +} diff --git a/core/swc/src/util/im-draft.ts b/core/swc/src/util/im-draft.ts new file mode 100644 index 00000000..710fe6fe --- /dev/null +++ b/core/swc/src/util/im-draft.ts @@ -0,0 +1,26 @@ +const PREFIX = "sum-im-draft:"; + +export function readImDraft(peerId: number): string { + if (peerId <= 0) return ""; + try { + return localStorage.getItem(PREFIX + String(peerId)) ?? ""; + } catch { + return ""; + } +} + +export function writeImDraft(peerId: number, text: string): void { + if (peerId <= 0) return; + try { + const key = PREFIX + String(peerId); + const trimmed = text.trim(); + if (!trimmed) localStorage.removeItem(key); + else localStorage.setItem(key, text); + } catch { + /* ignore */ + } +} + +export function clearImDraft(peerId: number): void { + writeImDraft(peerId, ""); +} diff --git a/core/swc/src/util/shell-storage.ts b/core/swc/src/util/shell-storage.ts index 8c3e6d46..830d2bb6 100644 --- a/core/swc/src/util/shell-storage.ts +++ b/core/swc/src/util/shell-storage.ts @@ -28,11 +28,12 @@ export function writeBool(key: string, value: boolean): void { export function readActivityWidth(): number { try { const n = parseInt(localStorage.getItem(KEY_ACTIVITY_WIDTH) ?? "", 10); - if (n >= 200 && n <= 520) return n; + if (n >= 280 && n <= 520) return n; + if (n >= 200 && n < 280) return 280; } catch (err) { storageWarn("readActivityWidth", KEY_ACTIVITY_WIDTH, err); } - return 300; + return 320; } export function writeActivityWidth(px: number): void { diff --git a/core/swc/src/util/user-initials.ts b/core/swc/src/util/user-initials.ts new file mode 100644 index 00000000..a5e85086 --- /dev/null +++ b/core/swc/src/util/user-initials.ts @@ -0,0 +1,11 @@ +export function userInitials(name: string, login = ""): string { + const nm = name.trim(); + if (nm) { + const parts = nm.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return nm.slice(0, 2).toUpperCase(); + } + const lg = login.trim(); + if (lg.length >= 2) return lg.slice(0, 2).toUpperCase(); + return lg.slice(0, 1).toUpperCase() || "?"; +} diff --git a/core/swc/tests/shell/activity-log.test.ts b/core/swc/tests/shell/activity-log.test.ts new file mode 100644 index 00000000..df375e53 --- /dev/null +++ b/core/swc/tests/shell/activity-log.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { initActivityLog } from "../../src/shell/activity-log.js"; +import { ACTIVITY_CONTEXT } from "../../src/shell/activity-context.js"; +import type { SwcEnv } from "../../src/runtime/env.js"; +import { flushScheduledRenders } from "../../src/runtime/scheduler.js"; + +function mockEnv(getJSON: () => Promise): SwcEnv { + const handlers: Array<(payload: unknown) => void> = []; + return { + bootstrap: { swcApiBase: "/web/swc" }, + services: { + http: { getJSON: vi.fn(getJSON) }, + bus: { + subscribe: (topic: string, fn: (payload: unknown) => void) => { + if (topic === ACTIVITY_CONTEXT) handlers.push(fn); + return () => {}; + }, + emit: (_topic: string, payload: unknown) => { + for (const fn of handlers) fn(payload); + }, + }, + }, + } as unknown as SwcEnv; +} + +describe("activity-log", () => { + beforeEach(() => { + document.body.innerHTML = ` + + + `; + }); + + it("shows Log tab and empty state when recordLogEligible", async () => { + const env = mockEnv(async () => ({ items: [], hasLog: true })); + initActivityLog(env); + env.services.bus.emit(ACTIVITY_CONTEXT, { + model: "crm.lead", + recordId: 5, + recordMessagesEligible: false, + recordLogEligible: true, + }); + await vi.waitFor(() => { + flushScheduledRenders(); + const tab = document.querySelector('[data-sum-activity-tab="log"]') as HTMLElement; + expect(tab.hidden).toBe(false); + }); + const pane = document.getElementById("sum-activity-pane-log") as HTMLElement; + expect(pane.hidden).toBe(false); + expect(pane.textContent).toContain("No changes logged yet"); + }); +}); diff --git a/core/swc/tests/shell/internal-messages-panel.test.ts b/core/swc/tests/shell/internal-messages-panel.test.ts new file mode 100644 index 00000000..a406cd62 --- /dev/null +++ b/core/swc/tests/shell/internal-messages-panel.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { InternalMessagesPanel } from "../../src/shell/internal-messages-panel.js"; +import { SwcError } from "../../src/runtime/error.js"; +import type { SwcEnv } from "../../src/runtime/env.js"; +import { flushScheduledRenders } from "../../src/runtime/scheduler.js"; + +function panelEnv(fetchImpl: (url: string) => Promise): SwcEnv { + return { + bootstrap: { swcApiBase: "/web/swc", internalMessagesEnabled: true }, + services: { + http: { + getJSON: vi.fn((url: string) => fetchImpl(url)), + postForm: vi.fn(), + postMultipart: vi.fn(), + }, + bus: { subscribe: () => () => {} }, + }, + } as unknown as SwcEnv; +} + +describe("InternalMessagesPanel", () => { + it("keeps search UI when conversations fail but directory loads", async () => { + const env = panelEnv(async (url) => { + if (url.includes("/direct/status")) { + return { enabled: true, unread: 0 }; + } + if (url.includes("/direct/conversations")) { + throw new SwcError("GET failed: 500", "http_get"); + } + if (url.includes("/direct/users")) { + return { users: [{ id: 2, name: "Alex", login: "alex" }] }; + } + return {}; + }); + const panel = new InternalMessagesPanel({}, env); + panel.callSetup(); + const mount = document.createElement("div"); + document.body.appendChild(mount); + mount.replaceChildren(panel.render()); + flushScheduledRenders(); + await vi.waitFor(() => { + flushScheduledRenders(); + expect(mount.querySelector(".sum-msg-search")).toBeTruthy(); + expect(mount.textContent).toContain("Alex"); + }); + expect(mount.querySelector(".sum-msg-empty")).toBeFalsy(); + }); + + it("shows company link when IM disabled via status", async () => { + const env = panelEnv(async (url) => { + if (url.includes("/direct/status")) { + return { enabled: false, companyFormHref: "/web?model=core.company&id=1" }; + } + return {}; + }); + env.bootstrap.internalMessagesEnabled = false; + const panel = new InternalMessagesPanel({}, env); + panel.callSetup(); + const mount = document.createElement("div"); + document.body.appendChild(mount); + mount.replaceChildren(panel.render()); + flushScheduledRenders(); + await vi.waitFor(() => { + flushScheduledRenders(); + expect(mount.textContent).toContain("company settings"); + }); + }); + + it("fuzzy search surfaces John for jn", async () => { + const env = panelEnv(async (url) => { + if (url.includes("/direct/status")) return { enabled: true }; + if (url.includes("/direct/conversations")) return { conversations: [] }; + if (url.includes("/direct/users")) { + return { + users: [ + { id: 1, name: "Jane Doe", login: "jane" }, + { id: 2, name: "John Smith", login: "john" }, + ], + }; + } + return {}; + }); + const panel = new InternalMessagesPanel({}, env); + panel.callSetup(); + const mount = document.createElement("div"); + document.body.appendChild(mount); + mount.replaceChildren(panel.render()); + await vi.waitFor(() => { + flushScheduledRenders(); + expect(mount.querySelector(".sum-msg-search")).toBeTruthy(); + }); + const input = mount.querySelector(".sum-msg-search") as HTMLInputElement; + input.value = "jn"; + input.dispatchEvent(new Event("input", { bubbles: true })); + flushScheduledRenders(); + await vi.waitFor(() => { + flushScheduledRenders(); + expect(mount.textContent).toContain("John"); + }); + }); +}); diff --git a/core/swc/tests/util/fuzzy.test.ts b/core/swc/tests/util/fuzzy.test.ts new file mode 100644 index 00000000..9b513d7e --- /dev/null +++ b/core/swc/tests/util/fuzzy.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { fuzzyScore, rankByFuzzy } from "../../src/util/fuzzy.js"; + +describe("fuzzy", () => { + it("fuzzyScore prefers prefix matches", () => { + expect(fuzzyScore("cr", "crm")).toBeGreaterThan(fuzzyScore("mx", "crm")); + }); + + it("rankByFuzzy orders users by name/login", () => { + const users = [ + { id: 1, name: "Jane Doe", login: "jane" }, + { id: 2, name: "John Smith", login: "john" }, + ]; + const ranked = rankByFuzzy("joh", users, (u) => [u.name, u.login]); + expect(ranked[0]?.item.id).toBe(2); + }); +}); diff --git a/core/swc/tests/util/shell-storage.test.ts b/core/swc/tests/util/shell-storage.test.ts index 203b38ab..ee8c3ba9 100644 --- a/core/swc/tests/util/shell-storage.test.ts +++ b/core/swc/tests/util/shell-storage.test.ts @@ -24,11 +24,11 @@ describe("shell-storage", () => { }); it("readActivityWidth clamps and defaults", () => { - expect(readActivityWidth()).toBe(300); + expect(readActivityWidth()).toBe(320); writeActivityWidth(999); - expect(readActivityWidth()).toBe(300); + expect(readActivityWidth()).toBe(320); writeActivityWidth(240); - expect(readActivityWidth()).toBe(240); + expect(readActivityWidth()).toBe(280); }); it("readJSON and writeJSON round-trip arrays", () => {