diff --git a/addons/README.md b/addons/README.md index 3534a8a1..c2319cc4 100644 --- a/addons/README.md +++ b/addons/README.md @@ -6,10 +6,16 @@ Installable modules shipped with the `sumeru` Go module. Each folder name equals | Module | Type | Depends | Purpose | |--------|------|---------|---------| -| **base** | Application | — | Kernel: users, companies, partners, geo, i18n, platform metadata | +| **base** | Application | — | Kernel: users, companies, partners, security | +| **audit** | Technical | base, platform | Audit trail, retention, exports (auto_install) | +| **platform** | Technical | base | Attachments, sequences, parameters, reports, import (auto_install) | +| **geo** | Technical | base | Countries, states, cities, currency (auto_install) | +| **i18n** | Technical | base | Internationalization: languages, translations, PO catalogs (auto_install) | +| **im** | Technical | base | Internal P2P chat (`im.message`, auto_install) | | **contacts** | Application | base | Contacts app over `core.partner` (reference layout) | -| **mail** | Technical | base | Chatter (`mail.message`), activities, `PostMessage` API | -| **automation** | Technical | base, mail | Cron, workflow transitions, server actions on events | +| **automation** | Technical | base, audit, im | Cron, workflow transitions, server actions | +| **calendar** | Application | base, im, contacts | Calendar events | +| **digest** | Technical | base, im | KPI digest cron | | **sumeru_ai** | Application | base | Optional AI shell hooks (`auto_import: false`) | ## Dependency graph @@ -17,32 +23,25 @@ Installable modules shipped with the `sumeru` Go module. Each folder name equals ```mermaid flowchart BT base[base] + platform[platform] + geo[geo] + i18n[i18n] + audit[audit] + im[im] contacts[contacts] - mail[mail] automation[automation] - sumeru_ai[sumeru_ai] + platform --> base + geo --> base + i18n --> base + audit --> base + audit --> platform + im --> base contacts --> base - mail --> base automation --> base - automation --> mail - sumeru_ai --> base + automation --> audit + automation --> im ``` -## Reference addon +## Standard -Use **[contacts/](contacts/)** as the gold standard for new modules: - -- Split views: `{model}_{type}_views.xml` -- Window actions in `views/actions.xml` with `` -- Security first in manifest, menus last -- Model extend in `models/partner_extend.go`, view inherit in `views/*_inherit_views.xml` - -Full rules: [core/module/addon_template/MODULE_STANDARD.txt](../core/module/addon_template/MODULE_STANDARD.txt) - -## Scaffold - -```bash -make bp NAME=my_module -make generate -go run ./cmd/sumeru -- -c sumeru.conf -i my_module -``` +See [core/module/addon_template/MODULE_STANDARD.txt](../core/module/addon_template/MODULE_STANDARD.txt) and **contacts** for layout conventions. diff --git a/addons/audit/audit_cleanup.go b/addons/audit/audit_cleanup.go new file mode 100644 index 00000000..d3755e59 --- /dev/null +++ b/addons/audit/audit_cleanup.go @@ -0,0 +1,98 @@ +package audit + +import ( + "context" + "fmt" + "strings" + + "sumeru/core/orm" +) + +const ( + auditCleanupModel = "sys.audit.cleanup" + auditPurgeBatchSize = 500 +) + +func init() { + orm.RegisterObjectAction(auditCleanupModel, "action_purge", purgeAuditLog) +} + +func purgeAuditLog(ctx context.Context, model string, id int, _ map[string]string) (string, error) { + if model != auditCleanupModel || id <= 0 { + return "", fmt.Errorf("invalid cleanup record") + } + if !orm.UserHasGroupXML(ctx, orm.SecurityUID(ctx), "base.group_system") { + return "", fmt.Errorf("access denied") + } + if err := orm.CheckModelAccess(ctx, orm.SecurityUID(ctx), auditModel, "unlink"); err != nil { + return "", fmt.Errorf("access denied") + } + rec, err := orm.SearchOne(ctx, auditCleanupModel, map[string]interface{}{"id": id}) + if err != nil { + return "", fmt.Errorf("record not found") + } + days, err := retentionDaysFromRecord(rec) + if err != nil { + return "", err + } + dryRun := orm.AsBool(rec["dry_run"]) + opts := RetentionOptions{ + RetentionDays: days, + FilterModel: strings.TrimSpace(orm.AsString(rec["filter_model"])), + DryRun: dryRun, + DeleteAfterExport: !dryRun, + Compress: true, + BatchSize: auditPurgeBatchSize, + MaxRowsPerRun: 50000, + } + if policy, perr := loadRetentionPolicy(ctx); perr == nil && len(policy) > 0 { + policyOpts := retentionOptionsFromPolicy(policy, dryRun) + opts.Compress = policyOpts.Compress + opts.LegalHold = policyOpts.LegalHold + if policyOpts.LegalHold { + opts.DeleteAfterExport = false + } + } + var result RetentionResult + err = orm.WithElevated(ctx, "audit_cleanup", func(elevCtx context.Context) error { + var runErr error + result, runErr = runAuditRetention(elevCtx, opts) + return runErr + }) + if err != nil { + return "", err + } + summary := result.Summary + if summary == "" { + summary = fmt.Sprintf("%d audit row(s) older than %d days", result.RowsDeleted+result.RowsExported, days) + } + if dryRun { + summary = "Dry run: " + summary + } else if result.RowsDeleted > 0 || result.RowsExported > 0 { + orm.AppendAudit(ctx, "audit_cleanup", auditModel, 0, nil, nil, + fmt.Sprintf("deleted=%d exported=%d days=%d model_filter=%q", result.RowsDeleted, result.RowsExported, days, strings.TrimSpace(orm.AsString(rec["filter_model"])))) + } + _ = orm.UpdateRecordByID(ctx, auditCleanupModel, id, map[string]interface{}{"result_summary": summary}) + return "", nil +} + +func retentionDaysFromRecord(rec map[string]interface{}) (int, error) { + preset := strings.TrimSpace(orm.AsString(rec["retention_preset"])) + switch preset { + case "7_days": + return 7, nil + case "30_days", "": + return 30, nil + case "custom": + d, ok := orm.CoerceInt64(rec["retention_days"]) + if !ok || d <= 0 { + return 0, fmt.Errorf("custom retention requires days greater than zero") + } + if d > 3650 { + return 0, fmt.Errorf("retention days too large") + } + return int(d), nil + default: + return 0, fmt.Errorf("unknown retention preset %q", preset) + } +} diff --git a/addons/audit/audit_export.go b/addons/audit/audit_export.go new file mode 100644 index 00000000..96c2634b --- /dev/null +++ b/addons/audit/audit_export.go @@ -0,0 +1,166 @@ +package audit + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "sort" + + "sumeru/core/orm" +) + +const ( + auditArchiveStateStored = "stored" + auditArchiveStateFailed = "failed" + auditArchiveStatePurged = "purged" +) + +// auditExportRow is one sys.audit row in NDJSON export. +type auditExportRow struct { + ID int64 `json:"id"` + Action string `json:"action"` + Model string `json:"model"` + ResID int64 `json:"res_id"` + UserID int64 `json:"user_id,omitempty"` + CreateDate string `json:"create_date"` + BeforeJSON string `json:"before_json,omitempty"` + AfterJSON string `json:"after_json,omitempty"` + Detail string `json:"detail,omitempty"` +} + +func auditRowFromMap(row map[string]interface{}) auditExportRow { + id, _ := orm.CoerceInt64(row["id"]) + resID, _ := orm.CoerceInt64(row["res_id"]) + uid, _ := orm.CoerceInt64(row["user_id"]) + return auditExportRow{ + ID: id, + Action: orm.AsString(row["action"]), + Model: orm.AsString(row["model"]), + ResID: resID, + UserID: uid, + CreateDate: orm.AsString(row["create_date"]), + BeforeJSON: orm.AsString(row["before_json"]), + AfterJSON: orm.AsString(row["after_json"]), + Detail: orm.AsString(row["detail"]), + } +} + +func encodeAuditJSONL(rows []auditExportRow) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + for _, row := range rows { + if err := enc.Encode(row); err != nil { + return nil, fmt.Errorf("jsonl encode: %w", err) + } + } + return buf.Bytes(), nil +} + +func gzipBytes(raw []byte) ([]byte, error) { + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + if _, err := w.Write(raw); err != nil { + _ = w.Close() + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func maybeGzipAudit(compress bool, raw []byte) (payload []byte, mime string, err error) { + if !compress { + return raw, "application/x-ndjson", nil + } + out, err := gzipBytes(raw) + if err != nil { + return nil, "", err + } + return out, "application/gzip", nil +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func auditPeriodBounds(rows []auditExportRow) (from, to string) { + if len(rows) == 0 { + return "", "" + } + dates := make([]string, len(rows)) + for i, r := range rows { + dates[i] = r.CreateDate + } + sort.Strings(dates) + return dates[0], dates[len(dates)-1] +} + +func auditMinMaxID(rows []auditExportRow) (minID, maxID int64) { + if len(rows) == 0 { + return 0, 0 + } + minID = rows[0].ID + maxID = rows[0].ID + for _, r := range rows[1:] { + if r.ID < minID { + minID = r.ID + } + if r.ID > maxID { + maxID = r.ID + } + } + return minID, maxID +} + +// DecodeAuditJSONLForTest parses NDJSON (tests). +func DecodeAuditJSONLForTest(data []byte) ([]auditExportRow, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + var rows []auditExportRow + for { + var row auditExportRow + if err := dec.Decode(&row); err != nil { + if err == io.EOF { + break + } + return nil, err + } + rows = append(rows, row) + } + return rows, nil +} + +// EncodeAuditJSONLForTest encodes rows (tests). +func EncodeAuditJSONLForTest(rows []auditExportRow) ([]byte, error) { + return encodeAuditJSONL(rows) +} + +// MaybeGzipAuditForTest compresses export payload (tests). +func MaybeGzipAuditForTest(compress bool, raw []byte) ([]byte, string, error) { + return maybeGzipAudit(compress, raw) +} + +// SHA256HexAuditForTest returns digest hex (tests). +func SHA256HexAuditForTest(data []byte) string { + return sha256Hex(data) +} + +// AuditPeriodBoundsForTest returns min/max create_date (tests). +func AuditPeriodBoundsForTest(rows []auditExportRow) (string, string) { + return auditPeriodBounds(rows) +} + +// AuditMinMaxIDForTest returns min/max row id (tests). +func AuditMinMaxIDForTest(rows []auditExportRow) (int64, int64) { + return auditMinMaxID(rows) +} + +// AuditRowFromMapForTest maps ORM row to export row (tests). +func AuditRowFromMapForTest(row map[string]interface{}) AuditExportRowForTest { + return auditRowFromMap(row) +} diff --git a/addons/audit/audit_retention.go b/addons/audit/audit_retention.go new file mode 100644 index 00000000..ff30ff56 --- /dev/null +++ b/addons/audit/audit_retention.go @@ -0,0 +1,313 @@ +package audit + +import ( + "context" + "fmt" + "strings" + "time" + + "sumeru/core/orm" + "sumeru/core/scheduler" +) + +const ( + auditModel = "sys.audit" + auditArchiveModel = "sys.audit.archive" + auditRetentionPolicyModel = "sys.audit.retention.policy" + auditRetentionCronCode = "audit.retention" +) + +// RetentionOptions configures one retention pass. +type RetentionOptions struct { + RetentionDays int + FilterModel string + DryRun bool + LegalHold bool + DeleteAfterExport bool + Compress bool + BatchSize int + MaxRowsPerRun int +} + +// RetentionResult aggregates one run. +type RetentionResult struct { + RowsExported int + RowsDeleted int + RowsEligible int + Batches int + Summary string +} + +func init() { + orm.RegisterObjectAction(auditRetentionPolicyModel, "action_run_retention", runRetentionFromPolicy) + scheduler.RegisterCronHandler(auditRetentionCronCode, runAuditRetentionCron) +} + +func runAuditRetentionCron(ctx context.Context, _ map[string]interface{}) error { + policy, err := loadRetentionPolicy(ctx) + if err != nil { + return nil + } + if !orm.AsBool(policy["auto_enabled"]) { + return nil + } + _, err = executeRetentionPolicy(ctx, policy, false) + return err +} + +func runRetentionFromPolicy(ctx context.Context, model string, id int, _ map[string]string) (string, error) { + if model != auditRetentionPolicyModel || id <= 0 { + return "", fmt.Errorf("invalid policy record") + } + if !orm.UserHasGroupXML(ctx, orm.SecurityUID(ctx), "base.group_system") { + return "", fmt.Errorf("access denied") + } + rec, err := orm.SearchOne(ctx, auditRetentionPolicyModel, map[string]interface{}{"id": id}) + if err != nil { + return "", fmt.Errorf("record not found") + } + dryRun := orm.AsBool(rec["dry_run_next"]) + summary, err := executeRetentionPolicy(ctx, rec, dryRun) + if err != nil { + return "", err + } + if dryRun { + _ = orm.UpdateRecordByID(ctx, auditRetentionPolicyModel, id, map[string]interface{}{"dry_run_next": false}) + } + return summary, nil +} + +func executeRetentionPolicy(ctx context.Context, policy map[string]interface{}, forceDryRun bool) (string, error) { + opts := retentionOptionsFromPolicy(policy, forceDryRun) + var result RetentionResult + err := orm.WithElevated(ctx, "audit_retention", func(elevCtx context.Context) error { + var runErr error + result, runErr = runAuditRetention(elevCtx, opts) + return runErr + }) + summary := result.Summary + if summary == "" && err != nil { + summary = err.Error() + } + policyID, _ := orm.CoerceInt64(policy["id"]) + if policyID > 0 { + _ = orm.UpdateRecordByID(ctx, auditRetentionPolicyModel, int(policyID), map[string]interface{}{ + "last_run_at": time.Now().UTC().Format(time.RFC3339), + "last_run_summary": summary, + }) + } + if err == nil && !opts.DryRun && (result.RowsExported > 0 || result.RowsDeleted > 0) { + orm.AppendAudit(ctx, "audit_retention", auditModel, 0, nil, nil, summary) + } + return summary, err +} + +func loadRetentionPolicy(ctx context.Context) (map[string]interface{}, error) { + rows, err := orm.SearchPage(ctx, auditRetentionPolicyModel, nil, 1, 0, "id ASC") + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, fmt.Errorf("audit retention policy not configured") + } + return rows[0], nil +} + +func retentionOptionsFromPolicy(policy map[string]interface{}, forceDryRun bool) RetentionOptions { + days, _ := orm.CoerceInt64(policy["hot_retention_days"]) + if days <= 0 { + if raw := strings.TrimSpace(orm.GetConfigParam(context.Background(), "audit.default_retention_days", "")); raw != "" { + if d, ok := orm.CoerceInt64(raw); ok && d > 0 { + days = d + } + } + } + if days <= 0 { + days = 90 + } + batch, _ := orm.CoerceInt64(policy["batch_size"]) + maxRows, _ := orm.CoerceInt64(policy["max_rows_per_run"]) + compress := true + if _, ok := policy["compress"]; ok { + compress = orm.AsBool(policy["compress"]) + } + return RetentionOptions{ + RetentionDays: int(days), + FilterModel: strings.TrimSpace(orm.AsString(policy["filter_model"])), + DryRun: forceDryRun || orm.AsBool(policy["dry_run_next"]), + LegalHold: orm.AsBool(policy["legal_hold"]), + DeleteAfterExport: orm.AsBool(policy["delete_after_export"]), + Compress: compress, + BatchSize: int(batch), + MaxRowsPerRun: int(maxRows), + } +} + +func runAuditRetention(ctx context.Context, opts RetentionOptions) (RetentionResult, error) { + opts = normalizeRetentionOptions(opts) + cutoff := time.Now().UTC().Add(-time.Duration(opts.RetentionDays) * 24 * time.Hour).Format(time.RFC3339) + domain := [][]interface{}{{"create_date", "<", cutoff}} + if fm := strings.TrimSpace(opts.FilterModel); fm != "" { + domain = append(domain, []interface{}{"model", "=", fm}) + } + + out := RetentionResult{} + if opts.DryRun { + n, err := orm.SearchCount(ctx, auditModel, domain) + if err != nil { + return out, err + } + if opts.MaxRowsPerRun > 0 && n > opts.MaxRowsPerRun { + n = opts.MaxRowsPerRun + } + out.RowsEligible = n + out.Summary = fmt.Sprintf("Dry run: %d audit row(s) older than %d days would be processed", n, opts.RetentionDays) + return out, nil + } + + var processed int + for processed < opts.MaxRowsPerRun { + limit := opts.BatchSize + if remaining := opts.MaxRowsPerRun - processed; remaining < limit { + limit = remaining + } + rows, err := orm.SearchPage(ctx, auditModel, domain, limit, 0, "id ASC") + if err != nil { + return out, err + } + if len(rows) == 0 { + break + } + if err := processAuditBatch(ctx, opts, rows, &out); err != nil { + out.Summary = err.Error() + return out, err + } + out.Batches++ + processed += len(rows) + if len(rows) < limit { + break + } + } + out.Summary = fmt.Sprintf("exported=%d deleted=%d batches=%d", out.RowsExported, out.RowsDeleted, out.Batches) + return out, nil +} + +func normalizeRetentionOptions(opts RetentionOptions) RetentionOptions { + if opts.RetentionDays <= 0 { + opts.RetentionDays = 90 + } + if opts.BatchSize <= 0 { + opts.BatchSize = 500 + } + if opts.MaxRowsPerRun <= 0 { + opts.MaxRowsPerRun = 5000 + } + return opts +} + +func processAuditBatch(ctx context.Context, opts RetentionOptions, rows []map[string]interface{}, out *RetentionResult) error { + exportRows := make([]auditExportRow, 0, len(rows)) + ids := make([]int64, 0, len(rows)) + for _, row := range rows { + exportRows = append(exportRows, auditRowFromMap(row)) + id, ok := orm.CoerceInt64(row["id"]) + if ok && id > 0 { + ids = append(ids, id) + } + } + if len(exportRows) == 0 { + return nil + } + + raw, err := encodeAuditJSONL(exportRows) + if err != nil { + return err + } + payload, mime, err := maybeGzipAudit(opts.Compress, raw) + if err != nil { + return err + } + periodFrom, periodTo := auditPeriodBounds(exportRows) + minID, maxID := auditMinMaxID(exportRows) + filename := fmt.Sprintf("audit_%s_%d_%d", time.Now().UTC().Format("20060102T150405"), minID, maxID) + if opts.Compress { + filename += ".jsonl.gz" + } else { + filename += ".jsonl" + } + + inst, ok := orm.Registry[auditArchiveModel] + if !ok { + return fmt.Errorf("%s not registered", auditArchiveModel) + } + archiveID, err := orm.Create(ctx, inst, map[string]interface{}{ + "name": filename, + "state": auditArchiveStateStored, + "period_from": periodFrom, + "period_to": periodTo, + "row_count": len(exportRows), + "byte_size": len(payload), + "sha256": sha256Hex(payload), + }) + if err != nil { + return err + } + + attID, err := orm.CreateBinaryAttachment(ctx, orm.CreateBinaryAttachmentInput{ + Name: filename, + ResModel: auditArchiveModel, + ResID: archiveID, + Data: payload, + Mimetype: mime, + }) + if err != nil { + _ = orm.UpdateRecordByID(ctx, auditArchiveModel, archiveID, map[string]interface{}{ + "state": auditArchiveStateFailed, + "error_message": err.Error(), + }) + return err + } + _ = orm.UpdateRecordByID(ctx, auditArchiveModel, archiveID, map[string]interface{}{ + "attachment_id": attID, + }) + out.RowsExported += len(exportRows) + + if opts.LegalHold || !opts.DeleteAfterExport { + return nil + } + if err := unlinkAuditIDs(ctx, ids); err != nil { + return err + } + out.RowsDeleted += len(ids) + _ = orm.UpdateRecordByID(ctx, auditArchiveModel, archiveID, map[string]interface{}{ + "state": auditArchiveStatePurged, + }) + return nil +} + +func unlinkAuditIDs(ctx context.Context, ids []int64) error { + for _, id := range ids { + if id <= 0 { + continue + } + if err := orm.Unlink(ctx, auditModel, int(id)); err != nil { + return err + } + } + return nil +} + +// RunAuditRetentionForTest runs retention with options (tests). +func RunAuditRetentionForTest(ctx context.Context, opts RetentionOptions) (RetentionResult, error) { + return runAuditRetention(ctx, opts) +} + +// RetentionOptionsFromPolicyForTest maps policy record to options (tests). +func RetentionOptionsFromPolicyForTest(policy map[string]interface{}, dryRun bool) RetentionOptions { + return retentionOptionsFromPolicy(policy, dryRun) +} + +// RunAuditRetentionCronForTest runs cron handler (tests). +func RunAuditRetentionCronForTest(ctx context.Context) error { + return runAuditRetentionCron(ctx, nil) +} diff --git a/addons/audit/data/sys_audit_retention_policy_data.xml b/addons/audit/data/sys_audit_retention_policy_data.xml new file mode 100644 index 00000000..a7b01e5e --- /dev/null +++ b/addons/audit/data/sys_audit_retention_policy_data.xml @@ -0,0 +1,15 @@ + + + + + Default audit retention + 90 + False + 500 + 5000 + True + False + False + + + diff --git a/addons/audit/init.go b/addons/audit/init.go new file mode 100644 index 00000000..e2a0f763 --- /dev/null +++ b/addons/audit/init.go @@ -0,0 +1,9 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package audit + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/platform" + _ "sumeru/addons/audit/models" +) diff --git a/addons/audit/manifest.json b/addons/audit/manifest.json new file mode 100644 index 00000000..504c8ca2 --- /dev/null +++ b/addons/audit/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "audit", + "display_name": "Audit & retention", + "version": "1.0.0", + "author": "Sumeru", + "description": "Audit trail, retention policy, exports, and archive downloads.", + "depends": ["base", "platform"], + "auto_install": true, + "application": false, + "data": [ + "security/sys.access.csv", + "data/sys_audit_retention_policy_data.xml", + "views/actions.xml", + "views/sys_audit_list_views.xml", + "views/sys_audit_form_views.xml", + "views/sys_audit_search_views.xml", + "views/sys_audit_retention_policy_list_views.xml", + "views/sys_audit_retention_policy_form_views.xml", + "views/sys_audit_archive_list_views.xml", + "views/sys_audit_archive_form_views.xml", + "views/sys_audit_cleanup_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/base/models/sys_audit.go b/addons/audit/models/sys_audit.go similarity index 100% rename from addons/base/models/sys_audit.go rename to addons/audit/models/sys_audit.go diff --git a/addons/audit/models/sys_audit_archive.go b/addons/audit/models/sys_audit_archive.go new file mode 100644 index 00000000..f6393e60 --- /dev/null +++ b/addons/audit/models/sys_audit_archive.go @@ -0,0 +1,21 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type SysAuditArchive struct { + sdk.Model `sumeru:"model=sys.audit.archive"` + + Name sdk.String `sumeru:"required,string=Name"` + State sdk.String `sumeru:"required,index,string=State,default=stored,selection=stored:Stored,failed:Failed,purged:Purged"` + PeriodFrom sdk.DateTime `sumeru:"string=Period from"` + PeriodTo sdk.DateTime `sumeru:"string=Period to"` + RowCount sdk.Integer `sumeru:"string=Rows"` + ByteSize sdk.Integer `sumeru:"string=Bytes"` + SHA256 sdk.String `sumeru:"string=SHA-256"` + AttachmentID sdk.Many2One[SysAttachment] `sumeru:"string=Download"` + Notes sdk.Text `sumeru:"string=Notes"` + ErrorMessage sdk.Text `sumeru:"string=Error"` + CreateDate sdk.DateTime `sumeru:"string=Created"` +} diff --git a/addons/audit/models/sys_audit_cleanup.go b/addons/audit/models/sys_audit_cleanup.go new file mode 100644 index 00000000..c311c888 --- /dev/null +++ b/addons/audit/models/sys_audit_cleanup.go @@ -0,0 +1,15 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type SysAuditCleanup struct { + sdk.Model `sumeru:"model=sys.audit.cleanup"` + + RetentionPreset sdk.String `sumeru:"required,string=Retention,default=30_days,selection=7_days:Older than 7 days,30_days:Older than 30 days,custom:Custom days"` + RetentionDays sdk.Integer `sumeru:"string=Custom days"` + FilterModel sdk.String `sumeru:"string=Limit to model"` + DryRun sdk.Boolean `sumeru:"string=Dry run only"` + ResultSummary sdk.Text `sumeru:"string=Result"` +} diff --git a/addons/audit/models/sys_audit_retention_policy.go b/addons/audit/models/sys_audit_retention_policy.go new file mode 100644 index 00000000..2badd261 --- /dev/null +++ b/addons/audit/models/sys_audit_retention_policy.go @@ -0,0 +1,22 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type SysAuditRetentionPolicy struct { + sdk.Model `sumeru:"model=sys.audit.retention.policy"` + + Name sdk.String `sumeru:"required,string=Name,default=Audit retention"` + HotRetentionDays sdk.Integer `sumeru:"required,string=Hot retention days,default=90"` + FilterModel sdk.String `sumeru:"string=Limit to model"` + AutoEnabled sdk.Boolean `sumeru:"string=Automatic retention,default=false"` + BatchSize sdk.Integer `sumeru:"string=Batch size,default=500"` + MaxRowsPerRun sdk.Integer `sumeru:"string=Max rows per run,default=5000"` + Compress sdk.Boolean `sumeru:"string=Gzip exports,default=true"` + DeleteAfterExport sdk.Boolean `sumeru:"string=Delete from database after export,default=false"` + LegalHold sdk.Boolean `sumeru:"string=Legal hold no deletes,default=false"` + DryRunNext sdk.Boolean `sumeru:"string=Dry run on next manual run,default=false"` + LastRunAt sdk.DateTime `sumeru:"string=Last run"` + LastRunSummary sdk.Text `sumeru:"string=Last run summary"` +} diff --git a/addons/audit/models/zmodels.go b/addons/audit/models/zmodels.go new file mode 100644 index 00000000..df1befb7 --- /dev/null +++ b/addons/audit/models/zmodels.go @@ -0,0 +1,14 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("audit", + &SysAudit{}, + &SysAuditArchive{}, + &SysAuditCleanup{}, + &SysAuditRetentionPolicy{}, + ) +} diff --git a/addons/audit/models/zrefs.go b/addons/audit/models/zrefs.go new file mode 100644 index 00000000..bb41edb8 --- /dev/null +++ b/addons/audit/models/zrefs.go @@ -0,0 +1,15 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import ( + basemodels "sumeru/addons/base/models" + platformmodels "sumeru/addons/platform/models" +) + +// CoreUser → core.user +type CoreUser = basemodels.CoreUser + +// SysAttachment → sys.attachment +type SysAttachment = platformmodels.SysAttachment + diff --git a/addons/audit/security/sys.access.csv b/addons/audit/security/sys.access.csv new file mode 100644 index 00000000..683aac80 --- /dev/null +++ b/addons/audit/security/sys.access.csv @@ -0,0 +1,5 @@ +id,name,model,group_id,perm_read,perm_write,perm_create,perm_unlink +access_sys_audit_cleanup_system,access_sys_audit_cleanup_system,sys.audit.cleanup,base.group_system,1,1,1,1 +access_sys_audit_unlink_system,access_sys_audit_unlink_system,sys.audit,base.group_system,1,0,0,1 +access_sys_audit_retention_policy_system,access_sys_audit_retention_policy_system,sys.audit.retention.policy,base.group_system,1,1,0,0 +access_sys_audit_archive_system,access_sys_audit_archive_system,sys.audit.archive,base.group_system,1,1,0,0 diff --git a/addons/audit/testexports.go b/addons/audit/testexports.go new file mode 100644 index 00000000..3ce9e636 --- /dev/null +++ b/addons/audit/testexports.go @@ -0,0 +1,9 @@ +package audit + +// AuditExportRowForTest is one exported audit row for tests. +type AuditExportRowForTest = auditExportRow + +// RetentionDaysFromRecordForTest exposes audit cleanup preset parsing for tests. +func RetentionDaysFromRecordForTest(rec map[string]interface{}) (int, error) { + return retentionDaysFromRecord(rec) +} diff --git a/addons/audit/views/actions.xml b/addons/audit/views/actions.xml new file mode 100644 index 00000000..3de55196 --- /dev/null +++ b/addons/audit/views/actions.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/addons/audit/views/menus.xml b/addons/audit/views/menus.xml new file mode 100644 index 00000000..f22902ab --- /dev/null +++ b/addons/audit/views/menus.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/addons/audit/views/sys_audit_archive_form_views.xml b/addons/audit/views/sys_audit_archive_form_views.xml new file mode 100644 index 00000000..af04c3c9 --- /dev/null +++ b/addons/audit/views/sys_audit_archive_form_views.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/audit/views/sys_audit_archive_list_views.xml b/addons/audit/views/sys_audit_archive_list_views.xml new file mode 100644 index 00000000..f3e05846 --- /dev/null +++ b/addons/audit/views/sys_audit_archive_list_views.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/addons/audit/views/sys_audit_cleanup_form_views.xml b/addons/audit/views/sys_audit_cleanup_form_views.xml new file mode 100644 index 00000000..74976cec --- /dev/null +++ b/addons/audit/views/sys_audit_cleanup_form_views.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + +

Exports matching rows then deletes them unless dry run or legal hold on Audit retention policy. Prefer Audit retention for scheduled export and database cleanup.

+
+
+
+
+
+
\ No newline at end of file diff --git a/addons/base/views/sys_audit_form_views.xml b/addons/audit/views/sys_audit_form_views.xml similarity index 100% rename from addons/base/views/sys_audit_form_views.xml rename to addons/audit/views/sys_audit_form_views.xml diff --git a/addons/base/views/sys_audit_list_views.xml b/addons/audit/views/sys_audit_list_views.xml similarity index 100% rename from addons/base/views/sys_audit_list_views.xml rename to addons/audit/views/sys_audit_list_views.xml diff --git a/addons/audit/views/sys_audit_retention_policy_form_views.xml b/addons/audit/views/sys_audit_retention_policy_form_views.xml new file mode 100644 index 00000000..b30e8470 --- /dev/null +++ b/addons/audit/views/sys_audit_retention_policy_form_views.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + +

Older audit rows are exported as JSONL files. Download them under Audit archives and store off-site as your organization requires. S3 R2 and similar uploads belong in a separate addon not shipped with the core engine. Activity Log and Audit Trail only show rows still in the database.

+
+
+
+
+
+
diff --git a/addons/audit/views/sys_audit_retention_policy_list_views.xml b/addons/audit/views/sys_audit_retention_policy_list_views.xml new file mode 100644 index 00000000..f55d5a5f --- /dev/null +++ b/addons/audit/views/sys_audit_retention_policy_list_views.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/audit/views/sys_audit_search_views.xml b/addons/audit/views/sys_audit_search_views.xml new file mode 100644 index 00000000..4bf3d729 --- /dev/null +++ b/addons/audit/views/sys_audit_search_views.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + 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/addons/automation/data/platform_bulk_job_cron.xml b/addons/automation/data/platform_bulk_job_cron.xml new file mode 100644 index 00000000..8af056bb --- /dev/null +++ b/addons/automation/data/platform_bulk_job_cron.xml @@ -0,0 +1,11 @@ + + + + + Platform bulk import/export jobs + platform.bulk_job + 1 + True + + + diff --git a/addons/automation/init.go b/addons/automation/init.go index daac638a..29e5d501 100644 --- a/addons/automation/init.go +++ b/addons/automation/init.go @@ -4,6 +4,7 @@ package automation import ( _ "sumeru/addons/base" - _ "sumeru/addons/mail" + _ "sumeru/addons/im" + _ "sumeru/addons/audit" _ "sumeru/addons/automation/models" ) diff --git a/addons/automation/manifest.json b/addons/automation/manifest.json index 5088eab1..3025556e 100644 --- a/addons/automation/manifest.json +++ b/addons/automation/manifest.json @@ -4,9 +4,11 @@ "version": "1.0.0", "author": "Sumeru", "description": "Scheduled jobs, workflow transitions, server actions, and event bindings.", - "depends": ["base", "mail"], + "depends": ["base", "audit", "im"], "application": false, "data": [ + "data/platform_bulk_job_cron.xml", + "data/audit_retention_cron.xml", "security/security.xml", "security/sys.access.csv", "views/actions.xml", diff --git a/addons/base/README.md b/addons/base/README.md index 19e5ab71..6b21e2bc 100644 --- a/addons/base/README.md +++ b/addons/base/README.md @@ -1,13 +1,25 @@ # Base -Sumeru kernel module: users, companies, partners, geo, i18n, platform metadata, and security administration. +Sumeru kernel module: users, companies, partners, security administration, and settings hub. -See [addons/README.md](../README.md) for the standard addon layout. Contacts is the reference app module; **base** is the largest module and follows the same conventions for actions, split views, and menu load order. +Related auto-installed modules: + +| Module | Role | +|--------|------| +| **audit** | Audit trail, retention, exports | +| **platform** | Attachments, sequences, parameters, reports, import templates | +| **geo** | Regional data (countries, states, cities, currency) | +| **i18n** | Internationalization (languages, translations, PO catalogs) | +| **im** | Internal user-to-user chat (activity dock Messages tab) | + +See [addons/README.md](../README.md) for the standard addon layout. ## Install -Base is installed automatically with the server. To reload XML: +Base is installed automatically with the server. To reload XML after changes: ```bash -go run ./cmd/sumeru -- -c sumeru.conf -u base +go run ./cmd/sumeru -- -c sumeru.conf -u base -u audit -u platform -u geo -u i18n -u im ``` + +PO catalogs under `addons/base/i18n/*.po` load automatically on `-u base`. diff --git a/addons/base/i18n/en_US.po b/addons/base/i18n/en_US.po new file mode 100644 index 00000000..6e74d4dd --- /dev/null +++ b/addons/base/i18n/en_US.po @@ -0,0 +1,7 @@ +# Sample catalog for the base module (loaded on -u base). +msgid "" +msgstr "" +"Language: en_US\n" + +msgid "Settings" +msgstr "Settings" diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 27155d56..753acb68 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -7,19 +7,11 @@ "application": true, "data": [ "data/core_company_data.xml", - "data/core_lang_data.xml", - "data/core_geo_data.xml", "views/core_company_actions.xml", "views/core_users_actions.xml", "views/platform_actions.xml", "views/res_config_settings_form_views.xml", - "views/geo_actions.xml", - "views/i18n_actions.xml", "views/apikey_actions.xml", - "views/sys_bulk_import_actions.xml", - "views/sys_report_action_actions.xml", - "views/sys_report_action_list_views.xml", - "views/sys_report_action_form_views.xml", "views/security_actions.xml", "views/auth_actions.xml", "views/sys_auth_provider_list_views.xml", @@ -40,27 +32,8 @@ "views/sys_access_form_views.xml", "views/sys_rule_list_views.xml", "views/sys_rule_form_views.xml", - "views/sys_audit_list_views.xml", - "views/sys_audit_form_views.xml", - "views/sys_attachment_list_views.xml", - "views/sys_attachment_form_views.xml", - "views/sys_sequence_list_views.xml", - "views/sys_sequence_form_views.xml", - "views/sys_config_parameter_list_views.xml", - "views/sys_config_parameter_form_views.xml", - "views/core_country_list_views.xml", - "views/core_country_form_views.xml", - "views/core_country_state_list_views.xml", - "views/core_country_state_form_views.xml", - "views/core_city_list_views.xml", - "views/core_city_form_views.xml", - "views/core_lang_list_views.xml", - "views/core_lang_form_views.xml", - "views/sys_translation_list_views.xml", - "views/sys_translation_form_views.xml", "views/sys_field_access_list_views.xml", "views/sys_field_access_form_views.xml", - "views/sys_bulk_import_form_views.xml", "views/menus.xml" ] } diff --git a/addons/base/models/regional_refs.go b/addons/base/models/regional_refs.go new file mode 100644 index 00000000..a3167a73 --- /dev/null +++ b/addons/base/models/regional_refs.go @@ -0,0 +1,9 @@ +package models + +import geomodels "sumeru/addons/geo/models" + +// Regional types owned by the geo addon (aliases for base Many2one fields). +type CoreCountry = geomodels.CoreCountry +type CoreCountryState = geomodels.CoreCountryState +type CoreCity = geomodels.CoreCity +type CoreCurrency = geomodels.CoreCurrency diff --git a/addons/base/models/zmodels.go b/addons/base/models/zmodels.go index 5150e402..6244ab1e 100644 --- a/addons/base/models/zmodels.go +++ b/addons/base/models/zmodels.go @@ -6,13 +6,8 @@ import "sumeru/core/sdk" func init() { sdk.MustRegister("base", - &CoreCity{}, &CoreCompany{}, - &CoreCountry{}, - &CoreCountryState{}, - &CoreCurrency{}, &CoreGroup{}, - &CoreLang{}, &CorePartner{}, &CoreUser{}, &CoreUserAPIKey{}, @@ -20,14 +15,7 @@ func init() { &CoreUserLog{}, &CoreUserTrustedDevice{}, &ResConfigSettings{}, - &SysAttachment{}, - &SysAudit{}, &SysAuthProvider{}, - &SysBulkImport{}, - &SysConfigParameter{}, &SysFieldAccess{}, - &SysReportAction{}, - &SysSequence{}, - &SysTranslation{}, ) } diff --git a/addons/base/views/core_company_form_views.xml b/addons/base/views/core_company_form_views.xml index 756f36a5..4b3a7e5a 100644 --- a/addons/base/views/core_company_form_views.xml +++ b/addons/base/views/core_company_form_views.xml @@ -64,9 +64,6 @@ - - - diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 04e59d21..0724ab1a 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -1,60 +1,72 @@ - - - + + + - - + + - - + + - - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + - - + + - + \ No newline at end of file diff --git a/addons/base/views/platform_actions.xml b/addons/base/views/platform_actions.xml index 9be4584b..094390a5 100644 --- a/addons/base/views/platform_actions.xml +++ b/addons/base/views/platform_actions.xml @@ -1,10 +1,6 @@ - - - - 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 a6878d95..00000000 --- a/addons/base/views/sys_bulk_import_form_views.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - -
-
-
-
-
diff --git a/addons/calendar/init.go b/addons/calendar/init.go index 7eabfd7e..a8ff9d3a 100644 --- a/addons/calendar/init.go +++ b/addons/calendar/init.go @@ -5,6 +5,6 @@ package calendar import ( _ "sumeru/addons/base" _ "sumeru/addons/contacts" - _ "sumeru/addons/mail" + _ "sumeru/addons/im" _ "sumeru/addons/calendar/models" ) diff --git a/addons/calendar/manifest.json b/addons/calendar/manifest.json index 4dedbda6..963c74d8 100644 --- a/addons/calendar/manifest.json +++ b/addons/calendar/manifest.json @@ -4,7 +4,7 @@ "version": "1.0.0", "author": "Sumeru", "description": "Schedule meetings and link them to business documents.", - "depends": ["base", "mail", "contacts"], + "depends": ["base", "im", "contacts"], "application": true, "data": [ "security/security.xml", diff --git a/addons/digest/hooks.go b/addons/digest/hooks.go index 8d76e776..85a73fcb 100644 --- a/addons/digest/hooks.go +++ b/addons/digest/hooks.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" - "sumeru/addons/mail" "sumeru/core/applog" "sumeru/core/event" "sumeru/core/orm" @@ -76,5 +75,5 @@ func runDigest(ctx context.Context, digestRow map[string]interface{}) error { return nil } body := name + "\n" + strings.Join(lines, "\n") - return mail.PostMessage(ctx, "digest.digest", digestID, body, mail.SubtypeNotification, "Digest") + return orm.AppendAppLog(ctx, "digest", "KPI digest", body) } diff --git a/addons/digest/init.go b/addons/digest/init.go index 94533d82..09188832 100644 --- a/addons/digest/init.go +++ b/addons/digest/init.go @@ -4,6 +4,6 @@ package digest import ( _ "sumeru/addons/base" - _ "sumeru/addons/mail" + _ "sumeru/addons/im" _ "sumeru/addons/digest/models" ) diff --git a/addons/digest/manifest.json b/addons/digest/manifest.json index bdb40c3e..221c05d9 100644 --- a/addons/digest/manifest.json +++ b/addons/digest/manifest.json @@ -4,7 +4,7 @@ "version": "1.0.0", "author": "Sumeru", "description": "Periodic KPI digests for application metrics.", - "depends": ["base", "mail"], + "depends": ["base", "im"], "application": false, "data": [ "security/security.xml", diff --git a/addons/base/data/core_geo_data.xml b/addons/geo/data/core_geo_data.xml similarity index 100% rename from addons/base/data/core_geo_data.xml rename to addons/geo/data/core_geo_data.xml diff --git a/addons/mail/init.go b/addons/geo/init.go similarity index 67% rename from addons/mail/init.go rename to addons/geo/init.go index 38532797..4e3a1a48 100644 --- a/addons/mail/init.go +++ b/addons/geo/init.go @@ -1,8 +1,8 @@ // Code generated by sumeru-import-gen; DO NOT EDIT. -package mail +package geo import ( _ "sumeru/addons/base" - _ "sumeru/addons/mail/models" + _ "sumeru/addons/geo/models" ) diff --git a/addons/geo/manifest.json b/addons/geo/manifest.json new file mode 100644 index 00000000..41898a5d --- /dev/null +++ b/addons/geo/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "geo", + "display_name": "Regional data", + "version": "1.0.0", + "author": "Sumeru", + "description": "Countries, states, cities, and currencies.", + "depends": ["base"], + "auto_install": true, + "application": false, + "data": [ + "data/core_geo_data.xml", + "views/geo_actions.xml", + "views/core_country_list_views.xml", + "views/core_country_form_views.xml", + "views/core_country_state_list_views.xml", + "views/core_country_state_form_views.xml", + "views/core_city_list_views.xml", + "views/core_city_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/base/models/core_city.go b/addons/geo/models/core_city.go similarity index 100% rename from addons/base/models/core_city.go rename to addons/geo/models/core_city.go diff --git a/addons/base/models/core_country.go b/addons/geo/models/core_country.go similarity index 100% rename from addons/base/models/core_country.go rename to addons/geo/models/core_country.go diff --git a/addons/base/models/core_country_state.go b/addons/geo/models/core_country_state.go similarity index 100% rename from addons/base/models/core_country_state.go rename to addons/geo/models/core_country_state.go diff --git a/addons/base/models/core_currency.go b/addons/geo/models/core_currency.go similarity index 100% rename from addons/base/models/core_currency.go rename to addons/geo/models/core_currency.go diff --git a/addons/geo/models/zmodels.go b/addons/geo/models/zmodels.go new file mode 100644 index 00000000..9398eeb6 --- /dev/null +++ b/addons/geo/models/zmodels.go @@ -0,0 +1,14 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("geo", + &CoreCity{}, + &CoreCountry{}, + &CoreCountryState{}, + &CoreCurrency{}, + ) +} diff --git a/addons/base/views/core_city_form_views.xml b/addons/geo/views/core_city_form_views.xml similarity index 100% rename from addons/base/views/core_city_form_views.xml rename to addons/geo/views/core_city_form_views.xml diff --git a/addons/base/views/core_city_list_views.xml b/addons/geo/views/core_city_list_views.xml similarity index 100% rename from addons/base/views/core_city_list_views.xml rename to addons/geo/views/core_city_list_views.xml diff --git a/addons/base/views/core_country_form_views.xml b/addons/geo/views/core_country_form_views.xml similarity index 100% rename from addons/base/views/core_country_form_views.xml rename to addons/geo/views/core_country_form_views.xml diff --git a/addons/base/views/core_country_list_views.xml b/addons/geo/views/core_country_list_views.xml similarity index 100% rename from addons/base/views/core_country_list_views.xml rename to addons/geo/views/core_country_list_views.xml diff --git a/addons/base/views/core_country_state_form_views.xml b/addons/geo/views/core_country_state_form_views.xml similarity index 100% rename from addons/base/views/core_country_state_form_views.xml rename to addons/geo/views/core_country_state_form_views.xml diff --git a/addons/base/views/core_country_state_list_views.xml b/addons/geo/views/core_country_state_list_views.xml similarity index 100% rename from addons/base/views/core_country_state_list_views.xml rename to addons/geo/views/core_country_state_list_views.xml diff --git a/addons/base/views/geo_actions.xml b/addons/geo/views/geo_actions.xml similarity index 100% rename from addons/base/views/geo_actions.xml rename to addons/geo/views/geo_actions.xml diff --git a/addons/geo/views/menus.xml b/addons/geo/views/menus.xml new file mode 100644 index 00000000..98de3ebf --- /dev/null +++ b/addons/geo/views/menus.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/addons/i18n/README.md b/addons/i18n/README.md new file mode 100644 index 00000000..5563ad7a --- /dev/null +++ b/addons/i18n/README.md @@ -0,0 +1,34 @@ +# Internationalization (`i18n`) + +Languages, `sys.translation` rows, and **gettext-compatible PO catalogs**. + +## PO files per addon + +Place catalogs under the owning module: + +``` +addons//i18n/.po +``` + +Examples: `i18n/fr_FR.po`, `i18n/es.po`. Language comes from the PO header `Language:` line or from the filename (normalized to Sumeru codes like `fr_FR`). + +On **install** or **`-u `**, the server imports all `i18n/*.po` files into `sys.translation` with `module` set to that addon’s technical name. + +## CLI + +```bash +go run ./cmd/sumeru-i18n -- -c sumeru.conf import translations.csv +go run ./cmd/sumeru-i18n -- -c sumeru.conf export -o out.csv +go run ./cmd/sumeru-i18n -- -c sumeru.conf import-po -i catalog.po -m mymodule -l fr_FR +go run ./cmd/sumeru-i18n -- -c sumeru.conf export-po -o catalog.po -m mymodule -l fr_FR +``` + +## Upgrade from `localization` + +Uninstall or remove the old **`localization`** module row, then: + +```bash +go run ./cmd/sumeru -- -c sumeru.conf -u geo -u i18n +``` + +Model tables (`core.lang`, `sys.translation`, geo models) are unchanged; only owning modules and menus differ. diff --git a/addons/base/data/core_lang_data.xml b/addons/i18n/data/core_lang_data.xml similarity index 100% rename from addons/base/data/core_lang_data.xml rename to addons/i18n/data/core_lang_data.xml diff --git a/addons/i18n/init.go b/addons/i18n/init.go new file mode 100644 index 00000000..6ba21169 --- /dev/null +++ b/addons/i18n/init.go @@ -0,0 +1,8 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package i18n + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/i18n/models" +) diff --git a/addons/i18n/manifest.json b/addons/i18n/manifest.json new file mode 100644 index 00000000..ada2d37e --- /dev/null +++ b/addons/i18n/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "i18n", + "display_name": "Internationalization", + "version": "1.0.0", + "author": "Sumeru", + "description": "Languages, translated terms, and gettext PO catalogs.", + "depends": ["base"], + "auto_install": true, + "application": false, + "data": [ + "data/core_lang_data.xml", + "views/i18n_actions.xml", + "views/core_lang_list_views.xml", + "views/core_lang_form_views.xml", + "views/sys_translation_list_views.xml", + "views/sys_translation_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/base/models/core_lang.go b/addons/i18n/models/core_lang.go similarity index 100% rename from addons/base/models/core_lang.go rename to addons/i18n/models/core_lang.go diff --git a/addons/base/models/sys_translation.go b/addons/i18n/models/sys_translation.go similarity index 100% rename from addons/base/models/sys_translation.go rename to addons/i18n/models/sys_translation.go diff --git a/addons/i18n/models/zmodels.go b/addons/i18n/models/zmodels.go new file mode 100644 index 00000000..6ea059aa --- /dev/null +++ b/addons/i18n/models/zmodels.go @@ -0,0 +1,12 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("i18n", + &CoreLang{}, + &SysTranslation{}, + ) +} diff --git a/addons/base/views/core_lang_form_views.xml b/addons/i18n/views/core_lang_form_views.xml similarity index 100% rename from addons/base/views/core_lang_form_views.xml rename to addons/i18n/views/core_lang_form_views.xml diff --git a/addons/base/views/core_lang_list_views.xml b/addons/i18n/views/core_lang_list_views.xml similarity index 100% rename from addons/base/views/core_lang_list_views.xml rename to addons/i18n/views/core_lang_list_views.xml diff --git a/addons/base/views/i18n_actions.xml b/addons/i18n/views/i18n_actions.xml similarity index 100% rename from addons/base/views/i18n_actions.xml rename to addons/i18n/views/i18n_actions.xml diff --git a/addons/i18n/views/menus.xml b/addons/i18n/views/menus.xml new file mode 100644 index 00000000..ec385319 --- /dev/null +++ b/addons/i18n/views/menus.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/addons/base/views/sys_translation_form_views.xml b/addons/i18n/views/sys_translation_form_views.xml similarity index 100% rename from addons/base/views/sys_translation_form_views.xml rename to addons/i18n/views/sys_translation_form_views.xml diff --git a/addons/base/views/sys_translation_list_views.xml b/addons/i18n/views/sys_translation_list_views.xml similarity index 100% rename from addons/base/views/sys_translation_list_views.xml rename to addons/i18n/views/sys_translation_list_views.xml diff --git a/addons/im/README.md b/addons/im/README.md new file mode 100644 index 00000000..37c285b6 --- /dev/null +++ b/addons/im/README.md @@ -0,0 +1,15 @@ +# Internal chat (`im`) + +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` when present) + +- **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 new file mode 100644 index 00000000..121233b3 --- /dev/null +++ b/addons/im/direct.go @@ -0,0 +1,317 @@ +package im + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "sumeru/core/orm" +) + +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 { + return nil, nil + } + if err := orm.CheckModelAccess(ctx, uid, "core.user", "read"); err != nil { + return nil, err + } + 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}, + } + for _, tok := range strings.Fields(q) { + pattern := ilikePattern(tok) + domain = append(domain, []interface{}{"|"}) + 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 { + return nil, nil + } + if err := orm.CheckModelAccess(ctx, uid, MessageModel, "read"); err != nil { + return nil, err + } + if limit <= 0 || limit > 50 { + limit = 30 + } + domain := [][]interface{}{ + {"|"}, + {"sender_id", "=", uid}, + {"recipient_id", "=", uid}, + } + rows, err := orm.SearchPage(ctx, MessageModel, domain, 200, 0, "create_date DESC") + if err != nil { + return nil, err + } + type conv struct { + preview string + when string + } + seen := map[int]conv{} + order := []int{} + for _, row := range rows { + sid, _ := orm.CoerceInt64(row["sender_id"]) + rid, _ := orm.CoerceInt64(row["recipient_id"]) + var peer int + switch uid { + case int(sid): + peer = int(rid) + case int(rid): + peer = int(sid) + default: + continue + } + if peer <= 0 || peer == uid { + continue + } + if _, ok := seen[peer]; ok { + continue + } + preview := strings.TrimSpace(orm.AsString(row["body"])) + if len(preview) > 80 { + preview = preview[:77] + "…" + } + seen[peer] = conv{ + preview: preview, + when: strings.TrimSpace(orm.AsString(row["create_date"])), + } + order = append(order, peer) + if len(order) >= limit { + break + } + } + unreadByPeer := directUnreadBySender(ctx, uid) + out := make([]map[string]interface{}, 0, len(order)) + for _, peerID := range order { + c := seen[peerID] + name := UserDisplayName(ctx, peerID) + out = append(out, map[string]interface{}{ + "userId": peerID, + "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 { + return nil, fmt.Errorf("invalid peer") + } + if orm.DB == nil { + return nil, nil + } + if err := orm.CheckModelAccess(ctx, uid, MessageModel, "read"); err != nil { + return nil, err + } + if err := assertInternalUser(ctx, uid, peerID); err != nil { + return nil, err + } + rowsA, err := orm.SearchPage(ctx, MessageModel, [][]interface{}{ + {"sender_id", "=", uid}, + {"recipient_id", "=", peerID}, + }, 120, 0, "create_date ASC") + if err != nil { + return nil, err + } + rowsB, err := orm.SearchPage(ctx, MessageModel, [][]interface{}{ + {"sender_id", "=", peerID}, + {"recipient_id", "=", uid}, + }, 120, 0, "create_date ASC") + if err != nil { + return nil, err + } + rows := append(rowsA, rowsB...) + sortDirectMessages(rows) + out := make([]map[string]interface{}, 0, len(rows)) + 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 +} + +func listMessageAttachments(ctx context.Context, uid, messageID int) []map[string]interface{} { + if messageID <= 0 { + return nil + } + if err := orm.CheckModelAccess(ctx, uid, "sys.attachment", "read"); err != nil { + return nil + } + rows, err := orm.Search(ctx, "sys.attachment", [][]interface{}{ + {"model", "=", MessageModel}, + {"res_id", "=", messageID}, + }) + if err != nil { + return nil + } + out := make([]map[string]interface{}, 0, len(rows)) + for _, row := range rows { + id, _ := orm.CoerceInt64(row["id"]) + out = append(out, map[string]interface{}{ + "id": int(id), + "name": strings.TrimSpace(orm.AsString(row["name"])), + "url": fmt.Sprintf("/web/content/%d", id), + }) + } + return out +} + +// 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") + } + if err := orm.CheckModelAccess(ctx, uid, MessageModel, "create"); err != nil { + return 0, err + } + if err := assertInternalUser(ctx, uid, recipientID); err != nil { + return 0, err + } + inst, ok := orm.Registry[MessageModel] + if !ok { + return 0, fmt.Errorf("internal chat not available") + } + 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 + } + return id, nil +} + +func assertInternalUser(ctx context.Context, uid, peerID int) error { + if err := orm.CheckModelAccess(ctx, uid, "core.user", "read"); err != nil { + return err + } + u, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": peerID}) + if err != nil { + return fmt.Errorf("user not found") + } + if !orm.AsBool(u["active"]) { + return fmt.Errorf("user not available") + } + if strings.TrimSpace(orm.AsString(u["user_type"])) != "internal" { + return fmt.Errorf("direct messages are for internal users only") + } + return nil +} + +func sortDirectMessages(rows []map[string]interface{}) { + sort.Slice(rows, func(i, j int) bool { + a := strings.TrimSpace(orm.AsString(rows[i]["create_date"])) + b := strings.TrimSpace(orm.AsString(rows[j]["create_date"])) + return a < b + }) +} + +// UserDisplayName returns a label for a user id. +func UserDisplayName(ctx context.Context, userID int) string { + u, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": userID}) + if err != nil { + return fmt.Sprintf("User #%d", userID) + } + if nm := strings.TrimSpace(orm.AsString(u["name"])); nm != "" { + return nm + } + if lg := strings.TrimSpace(orm.AsString(u["login"])); lg != "" { + return lg + } + return fmt.Sprintf("User #%d", userID) +} + +// SortDirectMessagesForTest exposes sort for tests. +func SortDirectMessagesForTest(rows []map[string]interface{}) { + sortDirectMessages(rows) +} diff --git a/addons/im/init.go b/addons/im/init.go new file mode 100644 index 00000000..26b449ae --- /dev/null +++ b/addons/im/init.go @@ -0,0 +1,8 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package im + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/im/models" +) diff --git a/addons/im/manifest.json b/addons/im/manifest.json new file mode 100644 index 00000000..af8d8faf --- /dev/null +++ b/addons/im/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "im", + "display_name": "Internal chat", + "version": "1.0.0", + "author": "Sumeru", + "description": "Internal user-to-user messaging in the activity dock (no email or record chatter).", + "depends": ["base"], + "auto_install": true, + "application": false, + "data": [ + "security/sys.access.csv", + "views/core_company_form_inherit_views.xml" + ] +} diff --git a/addons/im/models/core_company_im.go b/addons/im/models/core_company_im.go new file mode 100644 index 00000000..058c4486 --- /dev/null +++ b/addons/im/models/core_company_im.go @@ -0,0 +1,11 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type CoreCompanyIM struct { + sdk.Model `sumeru:"inherit=core.company"` + + ImEnabled sdk.Boolean `sumeru:"string=Internal chat,default=true,column=im_enabled"` +} diff --git a/addons/im/models/im_message.go b/addons/im/models/im_message.go new file mode 100644 index 00000000..c16b2526 --- /dev/null +++ b/addons/im/models/im_message.go @@ -0,0 +1,17 @@ +package models + +import ( + "sumeru/core/sdk" +) + +type ImMessage struct { + sdk.Model `sumeru:"model=im.message"` + + SenderID sdk.Many2One[CoreUser] `sumeru:"required,index,string=From"` + 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/models/zmodels.go b/addons/im/models/zmodels.go new file mode 100644 index 00000000..a91d6491 --- /dev/null +++ b/addons/im/models/zmodels.go @@ -0,0 +1,12 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("im", + &CoreCompanyIM{}, + &ImMessage{}, + ) +} diff --git a/addons/im/models/zrefs.go b/addons/im/models/zrefs.go new file mode 100644 index 00000000..e2549663 --- /dev/null +++ b/addons/im/models/zrefs.go @@ -0,0 +1,11 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import ( + basemodels "sumeru/addons/base/models" +) + +// CoreUser → core.user +type CoreUser = basemodels.CoreUser + diff --git a/addons/im/security/sys.access.csv b/addons/im/security/sys.access.csv new file mode 100644 index 00000000..48e47054 --- /dev/null +++ b/addons/im/security/sys.access.csv @@ -0,0 +1,2 @@ +id,name,model,group_id,perm_read,perm_write,perm_create,perm_unlink +access_im_message_user,access_im_message_user,im.message,base.group_user,1,1,1,0 diff --git a/addons/im/settings.go b/addons/im/settings.go new file mode 100644 index 00000000..447ff483 --- /dev/null +++ b/addons/im/settings.go @@ -0,0 +1,68 @@ +package im + +import ( + "context" + "database/sql" + "strings" + + "sumeru/core/orm" +) + +// IMEnabled reports whether internal chat is enabled for the active company (default true). +func IMEnabled(ctx context.Context) bool { + if orm.DB == nil { + return true + } + companyID := orm.CompanyIDFromContext(ctx) + 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") + q := `SELECT im_enabled FROM ` + tn + var imEnabled sql.NullBool + var err error + if companyID > 0 { + err = orm.DB.QueryRowContext(ctx, q+` WHERE id = $1`, companyID).Scan(&imEnabled) + } else { + 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 imEnabled.Valid { + return imEnabled.Bool, 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/testexports.go b/addons/im/testexports.go new file mode 100644 index 00000000..b8e88817 --- /dev/null +++ b/addons/im/testexports.go @@ -0,0 +1,8 @@ +package im + +import "context" + +// UserDisplayNameForTest exposes peer display name resolution for tests. +func UserDisplayNameForTest(ctx context.Context, userID int) string { + return UserDisplayName(ctx, userID) +} 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/addons/mail/data/mail_activity_type_data.xml b/addons/mail/data/mail_activity_type_data.xml deleted file mode 100644 index 8e54c4ac..00000000 --- a/addons/mail/data/mail_activity_type_data.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - Email - Email - fa-envelope - - - Call - Call - fa-phone - phonecall - 2 - - - Meeting - Meeting - fa-users - - - To-Do - To-Do - fa-check - 5 - - - Document - Document - fa-upload - 5 - upload_file - - - Exception - fa-warning - False - - - diff --git a/addons/mail/data/mail_subtype_data.xml b/addons/mail/data/mail_subtype_data.xml deleted file mode 100644 index dd805b0c..00000000 --- a/addons/mail/data/mail_subtype_data.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - comment - User discussion - True - - - notification - System notification - True - True - - - diff --git a/addons/mail/followers.go b/addons/mail/followers.go deleted file mode 100644 index 2b75dca6..00000000 --- a/addons/mail/followers.go +++ /dev/null @@ -1,171 +0,0 @@ -package mail - -import ( - "context" - "fmt" - "html" - "regexp" - "strings" - - pkgmail "sumeru/core/mail" - "sumeru/core/orm" - "time" -) - -var mentionPattern = regexp.MustCompile(`@([a-zA-Z0-9._-]+)`) - -// SubscribeUserFollower ensures user_id follows the record (idempotent). -func SubscribeUserFollower(ctx context.Context, model string, resID int64, userID int) error { - if userID <= 0 || resID <= 0 || strings.TrimSpace(model) == "" { - return nil - } - if _, ok := orm.Registry["mail.follower"]; !ok { - return nil - } - existing, err := orm.SearchOne(ctx, "mail.follower", map[string]interface{}{ - "res_model": model, - "res_id": int(resID), - "user_id": userID, - }) - if err == nil && existing != nil { - return nil - } - inst := orm.Registry["mail.follower"] - _, err = orm.Create(ctx, inst, map[string]interface{}{ - "res_model": model, - "res_id": int(resID), - "user_id": userID, - "active": true, - }) - return err -} - -// NotifyMessageFollowers creates inbox notifications and optional email for a posted message. -func NotifyMessageFollowers(ctx context.Context, messageID int, authorUserID int) error { - if messageID <= 0 || orm.DB == nil { - return nil - } - msg, err := orm.SearchOne(ctx, "mail.message", map[string]interface{}{"id": messageID}) - if err != nil || msg == nil { - return err - } - model := rowString(msg, "model") - resID := int(rowInt64(msg, "core_id")) - subtype := rowString(msg, "subtype") - body := rowString(msg, "body") - if model == "" || resID <= 0 { - return nil - } - recipients := map[int]struct{}{} - followers, _ := orm.Search(ctx, "mail.follower", [][]interface{}{ - {"res_model", "=", model}, - {"res_id", "=", resID}, - {"active", "=", true}, - }) - for _, f := range followers { - uid := int(rowInt64(f, "user_id")) - if uid <= 0 || uid == authorUserID { - continue - } - recipients[uid] = struct{}{} - } - for _, login := range mentionPattern.FindAllStringSubmatch(body, -1) { - if len(login) < 2 { - continue - } - uid := userIDByLogin(ctx, login[1]) - if uid > 0 && uid != authorUserID { - recipients[uid] = struct{}{} - _ = SubscribeUserFollower(ctx, model, int64(resID), uid) - } - } - authorName := rowString(msg, "author") - subject := fmt.Sprintf("[%s] %s", model, truncate(body, 80)) - for uid := range recipients { - if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil { - continue - } - notifID, err := createNotification(ctx, uid, messageID, model, resID) - if err != nil { - continue - } - orm.PublishBusChannel(ctx, orm.UserNotificationsBusChannel(uid), map[string]interface{}{ - "notification_id": notifID, - "message_id": messageID, - "model": model, - "id": resID, - }) - if shouldEmailUser(ctx, uid) { - email := userEmail(ctx, uid) - if email != "" { - htmlBody := renderNotificationHTML(authorName, body, model, resID) - pkgmail.EnqueueHTML(ctx, email, subject, htmlBody, stripHTML(body)) - } - } - } - _ = subtype - return nil -} - -func createNotification(ctx context.Context, uid, messageID int, model string, resID int) (int, error) { - inst := orm.Registry["mail.notification"] - id, err := orm.Create(ctx, inst, map[string]interface{}{ - "user_id": uid, - "message_id": messageID, - "is_read": false, - "record_model": model, - "record_id": resID, - "create_date": time.Now().UTC().Format(time.RFC3339), - }) - return id, err -} - -func userIDByLogin(ctx context.Context, login string) int { - login = strings.TrimSpace(login) - if login == "" || orm.DB == nil { - return 0 - } - rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"login": login}) - if err != nil || rec == nil { - return 0 - } - return int(rowInt64(rec, "id")) -} - -func userEmail(ctx context.Context, uid int) string { - rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": uid}) - if err != nil || rec == nil { - return "" - } - return rowString(rec, "email") -} - -func shouldEmailUser(ctx context.Context, uid int) bool { - rec, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": uid}) - if err != nil || rec == nil { - return false - } - if v, ok := rec["notify_email"].(bool); ok { - return v - } - return true -} - -func renderNotificationHTML(author, body, model string, resID int) string { - esc := html.EscapeString(body) - authorEsc := html.EscapeString(author) - return "

" + authorEsc + " on " + html.EscapeString(model) + - " #" + fmt.Sprint(resID) + ":

" + strings.ReplaceAll(esc, "\n", "
") + "

" -} - -func stripHTML(s string) string { - return strings.TrimSpace(s) -} - -func truncate(s string, n int) string { - s = strings.TrimSpace(s) - if len(s) <= n { - return s - } - return s[:n] + "…" -} diff --git a/addons/mail/hooks.go b/addons/mail/hooks.go deleted file mode 100644 index 161d9089..00000000 --- a/addons/mail/hooks.go +++ /dev/null @@ -1,61 +0,0 @@ -package mail - -import ( - "context" - - "sumeru/core/event" - "sumeru/core/orm" -) - -func init() { - event.Subscribe(eventRecordCreated, onMailThreadRecordCreated) - event.Subscribe(eventRecordUpdated, onMailThreadRecordUpdated) -} - -const ( - eventRecordCreated = "record.created" - eventRecordUpdated = "record.updated" -) - -func onMailThreadRecordCreated(ctx context.Context, ev event.Event) error { - model, _ := ev.Payload["model"].(string) - id, _ := payloadInt(ev.Payload["id"]) - if model == "" || id <= 0 || !orm.ModelHasMailThread(model) { - return nil - } - uid := ev.Actor - if uid <= 0 { - uid = orm.SecurityUID(ctx) - } - return SubscribeUserFollower(ctx, model, int64(id), uid) -} - -func onMailThreadRecordUpdated(ctx context.Context, ev event.Event) error { - model, _ := ev.Payload["model"].(string) - id, _ := payloadInt(ev.Payload["id"]) - if model == "" || id <= 0 || !orm.ModelHasMailThread(model) { - return nil - } - rec, err := orm.SearchOne(ctx, model, map[string]interface{}{"id": id}) - if err != nil || rec == nil { - return nil - } - assignee, ok := payloadInt(rec["user_id"]) - if !ok || assignee <= 0 { - return nil - } - return SubscribeUserFollower(ctx, model, int64(id), assignee) -} - -func payloadInt(v interface{}) (int, bool) { - switch t := v.(type) { - case int: - return t, true - case int64: - return int(t), true - case float64: - return int(t), true - default: - return 0, false - } -} diff --git a/addons/mail/mail.go b/addons/mail/mail.go deleted file mode 100644 index 596d6b23..00000000 --- a/addons/mail/mail.go +++ /dev/null @@ -1,268 +0,0 @@ -package mail - -import ( - "context" - "database/sql" - "fmt" - "strings" - "time" - - "sumeru/core/applog" - "sumeru/core/orm" -) - -// Subtype values for mail.message rows. -const ( - SubtypeComment = "comment" - SubtypeNotification = "notification" - SubtypeModule = "module" -) - -type Row struct { - Body string - Subtype string - Author string - CreateDate time.Time - Model string - CoreID int64 -} - -type companyMailSettings struct { - id int64 - chatterEnabled bool - activityPanelEnabled bool -} - -func firstCompanyMailSettings(ctx context.Context) (companyMailSettings, bool) { - if orm.DB == nil { - return companyMailSettings{chatterEnabled: true, activityPanelEnabled: true}, false - } - companyID := orm.CompanyIDFromContext(ctx) - if companyID <= 0 { - companyID = orm.ActiveCompanyIDForUser(ctx, orm.SecurityUID(ctx)) - } - tn := orm.MustQuotedTableName("core.company") - var id sql.NullInt64 - var chatter, activity sql.NullBool - var err error - if companyID > 0 { - err = orm.DB.QueryRowContext(ctx, - `SELECT id, mail_chatter_enabled, mail_activity_panel_enabled FROM `+tn+` WHERE id = $1`, - companyID, - ).Scan(&id, &chatter, &activity) - } else { - err = orm.DB.QueryRowContext(ctx, - `SELECT id, mail_chatter_enabled, mail_activity_panel_enabled FROM `+tn+` ORDER BY id ASC LIMIT 1`, - ).Scan(&id, &chatter, &activity) - } - if err != nil { - return companyMailSettings{chatterEnabled: true, activityPanelEnabled: true}, false - } - out := companyMailSettings{chatterEnabled: true, activityPanelEnabled: true} - if id.Valid { - out.id = id.Int64 - } - if chatter.Valid { - out.chatterEnabled = chatter.Bool - } - if activity.Valid { - out.activityPanelEnabled = activity.Bool - } - return out, id.Valid -} - -// CompanyChatterEnabled reads mail_chatter_enabled from the first core.company row (default true). -func CompanyChatterEnabled(ctx context.Context) bool { - settings, _ := firstCompanyMailSettings(ctx) - return settings.chatterEnabled -} - -// CompanyActivityPanelEnabled reads mail_activity_panel_enabled from the first core.company row (default true). -func CompanyActivityPanelEnabled(ctx context.Context) bool { - settings, _ := firstCompanyMailSettings(ctx) - return settings.activityPanelEnabled -} - -// PostMessage inserts a mail.message row. author may be empty (stored as "System"). -func PostMessage(ctx context.Context, model string, coreID int64, body, subtype, author string) error { - if orm.DB == nil { - return fmt.Errorf("database not initialized") - } - model = strings.TrimSpace(model) - body = strings.TrimSpace(body) - subtype = strings.TrimSpace(subtype) - if model == "" || body == "" || subtype == "" { - return fmt.Errorf("model, body, and subtype are required") - } - if _, ok := orm.Registry[model]; !ok { - return fmt.Errorf("unknown model %q", model) - } - uid := orm.SecurityUID(ctx) - if err := orm.CheckModelAccess(ctx, uid, model, "write"); err != nil { - return err - } - if _, err := orm.SearchOne(ctx, model, map[string]interface{}{"id": int(coreID)}); err != nil { - return fmt.Errorf("record not found or access denied") - } - inst, ok := orm.Registry["mail.message"] - if !ok { - return fmt.Errorf("unknown model %q", "mail.message") - } - author = strings.TrimSpace(author) - if author == "" { - author = "System" - } - vals := map[string]interface{}{ - "model": model, - "core_id": int(coreID), - "body": body, - "subtype": subtype, - "author": author, - "create_date": time.Now().UTC(), - } - if settings, ok := firstCompanyMailSettings(ctx); ok && settings.id > 0 { - vals["company_id"] = int(settings.id) - } - msgID, err := orm.Create(ctx, inst, vals) - if err != nil { - return err - } - if subtype == SubtypeComment { - _ = NotifyMessageFollowers(ctx, msgID, uid) - } - return nil -} - -// ListCommentsForRecord returns user chatter lines (subtype comment) for a record, oldest first. -func ListCommentsForRecord(ctx context.Context, model string, coreID int64, limit int) ([]Row, error) { - if orm.DB == nil { - return nil, nil - } - model = strings.TrimSpace(model) - if model == "" { - return nil, fmt.Errorf("model is required") - } - if _, ok := orm.Registry[model]; !ok { - return nil, fmt.Errorf("unknown model %q", model) - } - if err := orm.CheckModelAccess(ctx, orm.SecurityUID(ctx), 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 limit <= 0 || limit > 500 { - limit = 120 - } - domain := [][]interface{}{ - {"model", "=", model}, - {"core_id", "=", int(coreID)}, - {"subtype", "=", SubtypeComment}, - } - records, err := orm.SearchPage(ctx, "mail.message", domain, limit, 0, "create_date ASC") - if err != nil { - return nil, err - } - return rowsFromSearchResults(records), nil -} - -// QueryActivityLog returns audit-oriented lines (module events, notifications, record saves). -// User chatter comments are excluded. Optional ctxModel/ctxID adds notifications on that record only. -func QueryActivityLog(ctx context.Context, limit int, ctxModel string, ctxID int64) ([]Row, error) { - if orm.DB == nil { - return nil, nil - } - if limit <= 0 || limit > 200 { - limit = 40 - } - uid := orm.SecurityUID(ctx) - if err := orm.CheckModelAccess(ctx, uid, "mail.message", "read"); err != nil { - return nil, err - } - ctxModel = strings.TrimSpace(ctxModel) - if ctxModel != "" && ctxID > 0 { - if _, ok := orm.Registry[ctxModel]; !ok { - ctxModel, ctxID = "", 0 - } - } - domain := [][]interface{}{{"subtype", "=", SubtypeNotification}} - if ctxModel != "" && ctxID > 0 { - if err := orm.CheckModelAccess(ctx, uid, ctxModel, "read"); err != nil { - return nil, err - } - if _, err := orm.SearchOne(ctx, ctxModel, map[string]interface{}{"id": int(ctxID)}); err != nil { - return nil, fmt.Errorf("record not found or access denied") - } - domain = append(domain, - []interface{}{"model", "=", ctxModel}, - []interface{}{"core_id", "=", int(ctxID)}, - ) - } - records, err := orm.SearchPage(ctx, "mail.message", domain, limit, 0, "create_date DESC") - if err != nil { - return nil, err - } - return rowsFromSearchResults(records), nil -} - -func rowsFromSearchResults(records []map[string]interface{}) []Row { - out := make([]Row, 0, len(records)) - for _, rec := range records { - r := Row{ - Body: rowString(rec, "body"), - Subtype: rowString(rec, "subtype"), - Author: rowString(rec, "author"), - Model: rowString(rec, "model"), - CoreID: rowInt64(rec, "core_id"), - } - switch v := rec["create_date"].(type) { - case time.Time: - r.CreateDate = v.UTC() - case *time.Time: - if v != nil { - r.CreateDate = v.UTC() - } - } - out = append(out, r) - } - return out -} - -func rowString(rec map[string]interface{}, key string) string { - if rec == nil { - return "" - } - switch v := rec[key].(type) { - case string: - return v - case []byte: - return string(v) - default: - return fmt.Sprint(v) - } -} - -func rowInt64(rec map[string]interface{}, key string) int64 { - if rec == nil { - return 0 - } - switch v := rec[key].(type) { - case int: - return int64(v) - case int32: - return int64(v) - case int64: - return v - case float64: - return int64(v) - default: - return 0 - } -} - -// LogModuleEvent records a module lifecycle line in app.log (not mail.message). -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/addons/mail/manifest.json b/addons/mail/manifest.json deleted file mode 100644 index e91f82eb..00000000 --- a/addons/mail/manifest.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "mail", - "display_name": "Discuss & Chatter", - "version": "1.0.0", - "author": "Sumeru", - "description": "Messaging, chatter, and activity notifications for all applications.", - "depends": ["base"], - "application": false, - "data": [ - "security/security.xml", - "security/sys.access.csv", - "data/mail_activity_type_data.xml", - "data/mail_subtype_data.xml", - "views/actions.xml", - "views/mail_message_list_views.xml", - "views/mail_message_form_views.xml", - "views/mail_activity_list_views.xml", - "views/mail_activity_form_views.xml", - "views/mail_activity_type_list_views.xml", - "views/mail_activity_type_form_views.xml", - "views/mail_activity_plan_list_views.xml", - "views/mail_activity_plan_form_views.xml", - "views/mail_activity_plan_template_list_views.xml", - "views/mail_activity_plan_template_form_views.xml", - "views/mail_template_list_views.xml", - "views/mail_template_form_views.xml", - "views/menus.xml" - ] -} diff --git a/addons/mail/models/core_user_mail.go b/addons/mail/models/core_user_mail.go deleted file mode 100644 index 5e728ea9..00000000 --- a/addons/mail/models/core_user_mail.go +++ /dev/null @@ -1,11 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type CoreUserMail struct { - sdk.Model `sumeru:"inherit=core.user"` - - NotifyEmail sdk.Boolean `sumeru:"string=Email Notifications,default=true,column=notify_email"` -} diff --git a/addons/mail/models/mail_activity.go b/addons/mail/models/mail_activity.go deleted file mode 100644 index f510310f..00000000 --- a/addons/mail/models/mail_activity.go +++ /dev/null @@ -1,18 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailActivity struct { - sdk.Model `sumeru:"model=mail.activity"` - - Name sdk.String `sumeru:"required,string=Activity"` - ResModel sdk.String `sumeru:"required,index,column=model,string=Model"` - ResID sdk.Integer `sumeru:"required,index,string=Record"` - UserID sdk.Many2One[CoreUser] `sumeru:"string=Assigned To"` - CompanyID sdk.Many2One[CoreCompany] `sumeru:"string=Company"` - Summary sdk.Text `sumeru:"string=Summary"` - DateDeadline sdk.Date `sumeru:"string=Due Date"` - State sdk.String `sumeru:"string=State,default=planned,selection=planned:Planned,done:Done,cancelled:Cancelled"` -} diff --git a/addons/mail/models/mail_activity_plan.go b/addons/mail/models/mail_activity_plan.go deleted file mode 100644 index 6cdb174b..00000000 --- a/addons/mail/models/mail_activity_plan.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailActivityPlan struct { - sdk.Model `sumeru:"model=mail.activity.plan"` - - Name sdk.String `sumeru:"required,string=Name"` - ResModel sdk.String `sumeru:"required,index,column=model,string=Model"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` - TemplateIDs sdk.One2Many[MailActivityPlanTemplate] `sumeru:"string=Activity Templates"` -} diff --git a/addons/mail/models/mail_activity_plan_template.go b/addons/mail/models/mail_activity_plan_template.go deleted file mode 100644 index 3b79c9ad..00000000 --- a/addons/mail/models/mail_activity_plan_template.go +++ /dev/null @@ -1,14 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailActivityPlanTemplate struct { - sdk.Model `sumeru:"model=mail.activity.plan.template"` - - PlanID sdk.Many2One[MailActivityPlan] `sumeru:"required,index,string=Plan"` - ActivityTypeID sdk.Many2One[MailActivityType] `sumeru:"required,string=Activity Type"` - Sequence sdk.Integer `sumeru:"string=Sequence,default=10"` - Summary sdk.String `sumeru:"string=Summary"` -} diff --git a/addons/mail/models/mail_activity_type.go b/addons/mail/models/mail_activity_type.go deleted file mode 100644 index 4518073a..00000000 --- a/addons/mail/models/mail_activity_type.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailActivityType struct { - sdk.Model `sumeru:"model=mail.activity.type"` - - Name sdk.String `sumeru:"required,string=Name"` - Summary sdk.String `sumeru:"string=Default Summary"` - Category sdk.String `sumeru:"string=Category"` - DelayCount sdk.Integer `sumeru:"string=Schedule Delay,default=0"` - DelayUnit sdk.Selection[string] `sumeru:"string=Delay Unit,selection=days:Days,hours:Hours,weeks:Weeks,default=days"` - Icon sdk.String `sumeru:"string=Icon"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/mail/models/mail_follower.go b/addons/mail/models/mail_follower.go deleted file mode 100644 index 52fd40e3..00000000 --- a/addons/mail/models/mail_follower.go +++ /dev/null @@ -1,16 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailFollower struct { - sdk.Model `sumeru:"model=mail.follower"` - - ResModel sdk.String `sumeru:"required,index,column=res_model,string=Document Model"` - ResID sdk.Integer `sumeru:"required,index,column=res_id,string=Document ID"` - UserID sdk.Many2One[CoreUser] `sumeru:"required,index,string=User"` - PartnerID sdk.Many2One[CorePartner] `sumeru:"index,string=Partner"` - SubtypeIds sdk.Many2Many[MailMessageSubtype] `sumeru:"string=Subtypes,table=mail_follower_subtype_rel,left=follower_id,right=subtype_id"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/mail/models/mail_message.go b/addons/mail/models/mail_message.go deleted file mode 100644 index 0344f17e..00000000 --- a/addons/mail/models/mail_message.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailMessage struct { - sdk.Model `sumeru:"model=mail.message"` - - ResModel sdk.String `sumeru:"required,index,column=model"` - CoreID sdk.Integer `sumeru:"required"` - Body sdk.Text `sumeru:"required"` - Subtype sdk.String `sumeru:"required"` - Author sdk.String - CreateDate sdk.DateTime `sumeru:"required"` - CompanyID sdk.Many2One[CoreCompany] `sumeru:"string=Company"` -} diff --git a/addons/mail/models/mail_message_subtype.go b/addons/mail/models/mail_message_subtype.go deleted file mode 100644 index f06cecc9..00000000 --- a/addons/mail/models/mail_message_subtype.go +++ /dev/null @@ -1,16 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailMessageSubtype struct { - sdk.Model `sumeru:"model=mail.message.subtype"` - - Name sdk.String `sumeru:"required,unique,string=Technical Name"` - Description sdk.String `sumeru:"string=Description"` - Internal sdk.Boolean `sumeru:"string=Internal,default=false"` - Default sdk.Boolean `sumeru:"string=Default,default=false"` - Hidden sdk.Boolean `sumeru:"string=Hidden,default=false"` - Sequence sdk.Integer `sumeru:"string=Sequence,default=10"` -} diff --git a/addons/mail/models/mail_notification.go b/addons/mail/models/mail_notification.go deleted file mode 100644 index d43fd79f..00000000 --- a/addons/mail/models/mail_notification.go +++ /dev/null @@ -1,18 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailNotification struct { - sdk.Model `sumeru:"model=mail.notification"` - - UserID sdk.Many2One[CoreUser] `sumeru:"required,index,string=User"` - MessageID sdk.Many2One[MailMessage] `sumeru:"required,index,string=Message"` - IsRead sdk.Boolean `sumeru:"string=Read,default=false"` - ReadDate sdk.DateTime `sumeru:"string=Read Date"` - RecordModel sdk.String `sumeru:"index,column=record_model,string=Record Model"` - RecordID sdk.Integer `sumeru:"index,column=record_id,string=Record ID"` - CompanyID sdk.Many2One[CoreCompany] `sumeru:"index,string=Company"` - CreateDate sdk.DateTime `sumeru:"required,string=Created"` -} diff --git a/addons/mail/models/mail_template.go b/addons/mail/models/mail_template.go deleted file mode 100644 index c7962710..00000000 --- a/addons/mail/models/mail_template.go +++ /dev/null @@ -1,15 +0,0 @@ -package models - -import ( - "sumeru/core/sdk" -) - -type MailTemplate struct { - sdk.Model `sumeru:"model=mail.template"` - - Name sdk.String `sumeru:"required,string=Name"` - ResModel sdk.String `sumeru:"required,index,column=model,string=Applies to"` - Subject sdk.String `sumeru:"string=Subject"` - BodyHTML sdk.Text `sumeru:"string=Body,column=body_html"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` -} diff --git a/addons/mail/models/zmodels.go b/addons/mail/models/zmodels.go deleted file mode 100644 index d9156c79..00000000 --- a/addons/mail/models/zmodels.go +++ /dev/null @@ -1,20 +0,0 @@ -// Code generated by sumeru-import-gen; DO NOT EDIT. - -package models - -import "sumeru/core/sdk" - -func init() { - sdk.MustRegister("mail", - &CoreUserMail{}, - &MailActivity{}, - &MailActivityPlan{}, - &MailActivityPlanTemplate{}, - &MailActivityType{}, - &MailFollower{}, - &MailMessage{}, - &MailMessageSubtype{}, - &MailNotification{}, - &MailTemplate{}, - ) -} diff --git a/addons/mail/security/security.xml b/addons/mail/security/security.xml deleted file mode 100644 index f74ad244..00000000 --- a/addons/mail/security/security.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - Discuss - 50 - - - - Discuss / User - - - - - - Mail Message: multi-company - mail.message - ["|", ["company_id", "=", false], ["company_id", "in", "$company_ids"]] - - - - - - - - - - Mail Activity: multi-company - mail.activity - ["|", ["company_id", "=", false], ["company_id", "in", "$company_ids"]] - - - - - - - - - diff --git a/addons/mail/security/sys.access.csv b/addons/mail/security/sys.access.csv deleted file mode 100644 index 18727f79..00000000 --- a/addons/mail/security/sys.access.csv +++ /dev/null @@ -1,10 +0,0 @@ -id,name,model,group_id,perm_read,perm_write,perm_create,perm_unlink -access_mail_message_user,access_mail_message_user,mail.message,base.group_user,1,1,1,0 -access_mail_activity_user,access_mail_activity_user,mail.activity,base.group_user,1,1,1,1 -access_mail_activity_type_user,access_mail_activity_type_user,mail.activity.type,base.group_system,1,1,1,1 -access_mail_activity_plan_user,access_mail_activity_plan_user,mail.activity.plan,base.group_system,1,1,1,1 -access_mail_activity_plan_template_user,access_mail_activity_plan_template_user,mail.activity.plan.template,base.group_system,1,1,1,1 -access_mail_template_user,access_mail_template_user,mail.template,base.group_system,1,1,1,1 -access_mail_follower_user,access_mail_follower_user,mail.follower,base.group_user,1,1,1,1 -access_mail_subtype_user,access_mail_subtype_user,mail.message.subtype,base.group_user,1,0,0,0 -access_mail_notification_user,access_mail_notification_user,mail.notification,base.group_user,1,1,1,0 diff --git a/addons/mail/views/actions.xml b/addons/mail/views/actions.xml deleted file mode 100644 index 7d6c3f39..00000000 --- a/addons/mail/views/actions.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/addons/mail/views/mail_activity_form_views.xml b/addons/mail/views/mail_activity_form_views.xml deleted file mode 100644 index dcc29540..00000000 --- a/addons/mail/views/mail_activity_form_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/addons/mail/views/mail_activity_list_views.xml b/addons/mail/views/mail_activity_list_views.xml deleted file mode 100644 index 7750ef19..00000000 --- a/addons/mail/views/mail_activity_list_views.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/addons/mail/views/mail_activity_plan_form_views.xml b/addons/mail/views/mail_activity_plan_form_views.xml deleted file mode 100644 index bcc61dda..00000000 --- a/addons/mail/views/mail_activity_plan_form_views.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addons/mail/views/mail_activity_plan_list_views.xml b/addons/mail/views/mail_activity_plan_list_views.xml deleted file mode 100644 index ec2c0950..00000000 --- a/addons/mail/views/mail_activity_plan_list_views.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/addons/mail/views/mail_activity_plan_template_form_views.xml b/addons/mail/views/mail_activity_plan_template_form_views.xml deleted file mode 100644 index be39871e..00000000 --- a/addons/mail/views/mail_activity_plan_template_form_views.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/addons/mail/views/mail_activity_plan_template_list_views.xml b/addons/mail/views/mail_activity_plan_template_list_views.xml deleted file mode 100644 index 58258bec..00000000 --- a/addons/mail/views/mail_activity_plan_template_list_views.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/addons/mail/views/mail_activity_type_form_views.xml b/addons/mail/views/mail_activity_type_form_views.xml deleted file mode 100644 index fbf03a4a..00000000 --- a/addons/mail/views/mail_activity_type_form_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/addons/mail/views/mail_activity_type_list_views.xml b/addons/mail/views/mail_activity_type_list_views.xml deleted file mode 100644 index 8a634b61..00000000 --- a/addons/mail/views/mail_activity_type_list_views.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/addons/mail/views/mail_message_form_views.xml b/addons/mail/views/mail_message_form_views.xml deleted file mode 100644 index 83812e30..00000000 --- a/addons/mail/views/mail_message_form_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/addons/mail/views/mail_message_list_views.xml b/addons/mail/views/mail_message_list_views.xml deleted file mode 100644 index e11ec836..00000000 --- a/addons/mail/views/mail_message_list_views.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/addons/mail/views/mail_template_form_views.xml b/addons/mail/views/mail_template_form_views.xml deleted file mode 100644 index 145ed9a7..00000000 --- a/addons/mail/views/mail_template_form_views.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/addons/mail/views/mail_template_list_views.xml b/addons/mail/views/mail_template_list_views.xml deleted file mode 100644 index 471a137d..00000000 --- a/addons/mail/views/mail_template_list_views.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/addons/mail/views/menus.xml b/addons/mail/views/menus.xml deleted file mode 100644 index 471967c6..00000000 --- a/addons/mail/views/menus.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/addons/platform/init.go b/addons/platform/init.go new file mode 100644 index 00000000..d322426a --- /dev/null +++ b/addons/platform/init.go @@ -0,0 +1,8 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package platform + +import ( + _ "sumeru/addons/base" + _ "sumeru/addons/platform/models" +) diff --git a/addons/platform/manifest.json b/addons/platform/manifest.json new file mode 100644 index 00000000..ebfba54d --- /dev/null +++ b/addons/platform/manifest.json @@ -0,0 +1,29 @@ +{ + "name": "platform", + "display_name": "Platform tools", + "version": "1.0.0", + "author": "Sumeru", + "description": "Attachments, sequences, system parameters, reports, and bulk import templates.", + "depends": ["base"], + "auto_install": true, + "application": false, + "data": [ + "security/sys.access.csv", + "views/actions.xml", + "views/sys_bulk_import_actions.xml", + "views/sys_import_template_actions.xml", + "views/sys_report_action_actions.xml", + "views/sys_report_action_list_views.xml", + "views/sys_report_action_form_views.xml", + "views/sys_attachment_list_views.xml", + "views/sys_attachment_form_views.xml", + "views/sys_sequence_list_views.xml", + "views/sys_sequence_form_views.xml", + "views/sys_config_parameter_list_views.xml", + "views/sys_config_parameter_form_views.xml", + "views/sys_bulk_import_form_views.xml", + "views/sys_import_template_list_views.xml", + "views/sys_import_template_form_views.xml", + "views/menus.xml" + ] +} diff --git a/addons/base/models/report_templates.go b/addons/platform/models/report_templates.go similarity index 100% rename from addons/base/models/report_templates.go rename to addons/platform/models/report_templates.go diff --git a/addons/base/models/sys_attachment.go b/addons/platform/models/sys_attachment.go similarity index 91% rename from addons/base/models/sys_attachment.go rename to addons/platform/models/sys_attachment.go index b3a2f6e9..2cce1db1 100644 --- a/addons/base/models/sys_attachment.go +++ b/addons/platform/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/addons/base/models/sys_bulk_import.go b/addons/platform/models/sys_bulk_import.go similarity index 62% rename from addons/base/models/sys_bulk_import.go rename to addons/platform/models/sys_bulk_import.go index 9a78cd1c..b8fe1382 100644 --- a/addons/base/models/sys_bulk_import.go +++ b/addons/platform/models/sys_bulk_import.go @@ -19,4 +19,12 @@ type SysBulkImport struct { 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/platform/models/sys_config_parameter.go similarity index 100% rename from addons/base/models/sys_config_parameter.go rename to addons/platform/models/sys_config_parameter.go diff --git a/addons/platform/models/sys_import_template.go b/addons/platform/models/sys_import_template.go new file mode 100644 index 00000000..7e6f1ce3 --- /dev/null +++ b/addons/platform/models/sys_import_template.go @@ -0,0 +1,18 @@ +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/platform/models/sys_report_action.go similarity index 100% rename from addons/base/models/sys_report_action.go rename to addons/platform/models/sys_report_action.go diff --git a/addons/base/models/sys_sequence.go b/addons/platform/models/sys_sequence.go similarity index 100% rename from addons/base/models/sys_sequence.go rename to addons/platform/models/sys_sequence.go diff --git a/addons/platform/models/zmodels.go b/addons/platform/models/zmodels.go new file mode 100644 index 00000000..8f73fac2 --- /dev/null +++ b/addons/platform/models/zmodels.go @@ -0,0 +1,16 @@ +// Code generated by sumeru-import-gen; DO NOT EDIT. + +package models + +import "sumeru/core/sdk" + +func init() { + sdk.MustRegister("platform", + &SysAttachment{}, + &SysBulkImport{}, + &SysConfigParameter{}, + &SysImportTemplate{}, + &SysReportAction{}, + &SysSequence{}, + ) +} diff --git a/addons/mail/models/zrefs.go b/addons/platform/models/zrefs.go similarity index 77% rename from addons/mail/models/zrefs.go rename to addons/platform/models/zrefs.go index 9c746168..bcec8b9c 100644 --- a/addons/mail/models/zrefs.go +++ b/addons/platform/models/zrefs.go @@ -9,9 +9,6 @@ import ( // CoreCompany → core.company type CoreCompany = basemodels.CoreCompany -// CorePartner → core.partner -type CorePartner = basemodels.CorePartner - // CoreUser → core.user type CoreUser = basemodels.CoreUser diff --git a/addons/platform/security/sys.access.csv b/addons/platform/security/sys.access.csv new file mode 100644 index 00000000..adf7c7eb --- /dev/null +++ b/addons/platform/security/sys.access.csv @@ -0,0 +1,2 @@ +id,name,model,group_id,perm_read,perm_write,perm_create,perm_unlink +access_sys_import_template_user,access_sys_import_template_user,sys.import.template,base.group_user,1,1,1,1 diff --git a/addons/platform/views/actions.xml b/addons/platform/views/actions.xml new file mode 100644 index 00000000..14fe3f99 --- /dev/null +++ b/addons/platform/views/actions.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/addons/platform/views/menus.xml b/addons/platform/views/menus.xml new file mode 100644 index 00000000..10d868ee --- /dev/null +++ b/addons/platform/views/menus.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/addons/base/views/sys_attachment_form_views.xml b/addons/platform/views/sys_attachment_form_views.xml similarity index 100% rename from addons/base/views/sys_attachment_form_views.xml rename to addons/platform/views/sys_attachment_form_views.xml diff --git a/addons/base/views/sys_attachment_list_views.xml b/addons/platform/views/sys_attachment_list_views.xml similarity index 100% rename from addons/base/views/sys_attachment_list_views.xml rename to addons/platform/views/sys_attachment_list_views.xml diff --git a/addons/base/views/sys_bulk_import_actions.xml b/addons/platform/views/sys_bulk_import_actions.xml similarity index 100% rename from addons/base/views/sys_bulk_import_actions.xml rename to addons/platform/views/sys_bulk_import_actions.xml diff --git a/addons/platform/views/sys_bulk_import_form_views.xml b/addons/platform/views/sys_bulk_import_form_views.xml new file mode 100644 index 00000000..22aa5e34 --- /dev/null +++ b/addons/platform/views/sys_bulk_import_form_views.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/addons/base/views/sys_config_parameter_form_views.xml b/addons/platform/views/sys_config_parameter_form_views.xml similarity index 100% rename from addons/base/views/sys_config_parameter_form_views.xml rename to addons/platform/views/sys_config_parameter_form_views.xml diff --git a/addons/base/views/sys_config_parameter_list_views.xml b/addons/platform/views/sys_config_parameter_list_views.xml similarity index 100% rename from addons/base/views/sys_config_parameter_list_views.xml rename to addons/platform/views/sys_config_parameter_list_views.xml diff --git a/addons/platform/views/sys_import_template_actions.xml b/addons/platform/views/sys_import_template_actions.xml new file mode 100644 index 00000000..f7119275 --- /dev/null +++ b/addons/platform/views/sys_import_template_actions.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/platform/views/sys_import_template_form_views.xml b/addons/platform/views/sys_import_template_form_views.xml new file mode 100644 index 00000000..fd6d7b5f --- /dev/null +++ b/addons/platform/views/sys_import_template_form_views.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/platform/views/sys_import_template_list_views.xml b/addons/platform/views/sys_import_template_list_views.xml new file mode 100644 index 00000000..4aa9b350 --- /dev/null +++ b/addons/platform/views/sys_import_template_list_views.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/addons/base/views/sys_report_action_actions.xml b/addons/platform/views/sys_report_action_actions.xml similarity index 100% rename from addons/base/views/sys_report_action_actions.xml rename to addons/platform/views/sys_report_action_actions.xml diff --git a/addons/base/views/sys_report_action_form_views.xml b/addons/platform/views/sys_report_action_form_views.xml similarity index 100% rename from addons/base/views/sys_report_action_form_views.xml rename to addons/platform/views/sys_report_action_form_views.xml diff --git a/addons/base/views/sys_report_action_list_views.xml b/addons/platform/views/sys_report_action_list_views.xml similarity index 100% rename from addons/base/views/sys_report_action_list_views.xml rename to addons/platform/views/sys_report_action_list_views.xml diff --git a/addons/base/views/sys_sequence_form_views.xml b/addons/platform/views/sys_sequence_form_views.xml similarity index 100% rename from addons/base/views/sys_sequence_form_views.xml rename to addons/platform/views/sys_sequence_form_views.xml diff --git a/addons/base/views/sys_sequence_list_views.xml b/addons/platform/views/sys_sequence_list_views.xml similarity index 100% rename from addons/base/views/sys_sequence_list_views.xml rename to addons/platform/views/sys_sequence_list_views.xml 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/cmd/sumeru/zimports.go b/cmd/sumeru/zimports.go index f399c739..7f66655b 100644 --- a/cmd/sumeru/zimports.go +++ b/cmd/sumeru/zimports.go @@ -5,8 +5,12 @@ package main import ( _ "sumeru/addons/base" _ "sumeru/addons/contacts" - _ "sumeru/addons/mail" - _ "sumeru/addons/automation" + _ "sumeru/addons/geo" + _ "sumeru/addons/i18n" + _ "sumeru/addons/im" _ "sumeru/addons/calendar" _ "sumeru/addons/digest" + _ "sumeru/addons/platform" + _ "sumeru/addons/audit" + _ "sumeru/addons/automation" ) diff --git a/core/engine/assets/css/sumeru-messages.css b/core/engine/assets/css/sumeru-messages.css index 6b4662b2..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-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-card-head { +.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-thread-empty { +.sum-msg-peer-btn.is-highlight, +.sum-msg-peer-btn:hover { + background: var(--sum-row-hover-bg); +} + +.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); } @@ -195,3 +479,72 @@ outline: none; box-shadow: var(--sum-focus-ring); } + +.sum-msg-send:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.sum-msg-form-hint { + margin: 0.3rem 0 0; + font-size: 0.625rem; + color: var(--sum-muted); + text-align: center; +} + +.sum-msg-skeleton { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.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-skeleton-line--short { + width: 55%; +} + +@keyframes sum-msg-shimmer { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } +} + +/* Legacy card classes (unused in dock UI; kept for compatibility) */ +.sum-msg-card { + border-radius: var(--sum-radius-sm); + padding: 0.55rem 0.65rem; + background: var(--sum-surface); + border: 1px solid var(--sum-line); +} + +.sum-msg-author { + font-size: var(--sum-font-size-sm); + font-weight: var(--sum-font-weight-semibold); + 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 9f576991..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); @@ -918,9 +930,15 @@ padding: 0.65rem 0.75rem; } +.sum-activity-pane[hidden] { + display: none !important; +} + .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/engine/assets/css/sumeru-views.css b/core/engine/assets/css/sumeru-views.css index b8c22753..9547abf6 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; @@ -2948,6 +3009,32 @@ label.sum-form-label--hint { margin: 0.5rem 0; } +.sum-chatter-composer-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.sum-chatter--disabled { + color: var(--sum-muted); + font-size: var(--sum-font-size-sm); + padding: 0.5rem 0; +} + +.sum-chatter--embedded { + border: none; + padding: 0; + box-shadow: none; + background: transparent; +} + +.sum-chatter-attachments--inline { + margin: 0.5rem 0; + padding: 0; + list-style: none; +} + .sum-chatter-composer { display: flex; flex-direction: column; diff --git a/core/engine/render/branding.go b/core/engine/render/branding.go index a04b98e6..b98c5d6e 100644 --- a/core/engine/render/branding.go +++ b/core/engine/render/branding.go @@ -2,12 +2,9 @@ package render import ( "context" - "fmt" "html/template" "strings" - "time" - "sumeru/addons/mail" "sumeru/core/orm" "sumeru/core/server/config" ) @@ -112,7 +109,8 @@ func EnrichShellPageData(ctx context.Context, d *PageData) { } d.ShellUserInitials = UserInitialsFromName(d.ShellUser) - d.ActivityEnabled = mail.CompanyChatterEnabled(ctx) && mail.CompanyActivityPanelEnabled(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 } @@ -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/render/swc_bootstrap.go b/core/engine/render/swc_bootstrap.go index 3c2ecb55..33d9dbdb 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" ) @@ -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 mail.CompanyChatterEnabled(ctx) && mail.CompanyActivityPanelEnabled(ctx) + return page.ActivityEnabled } func parseSWCPinnedApps(raw template.JS) []string { diff --git a/core/engine/render/templates_paths.go b/core/engine/render/templates_paths.go index f23ad96b..768ffa9e 100644 --- a/core/engine/render/templates_paths.go +++ b/core/engine/render/templates_paths.go @@ -26,6 +26,7 @@ const ( TemplatePagesSettingsAccount = "pages/settings_account.html" TemplatePagesSettingsFieldACL = "pages/settings_field_acl.html" TemplatePagesSettingsModelACL = "pages/settings_model_acl.html" + TemplatePagesImportWizard = "pages/import_wizard.html" ) // TemplatePath joins templatesDir with a relative template path. 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/engine/templates/pages/import_wizard.html b/core/engine/templates/pages/import_wizard.html new file mode 100644 index 00000000..e8363f03 --- /dev/null +++ b/core/engine/templates/pages/import_wizard.html @@ -0,0 +1,8 @@ +
+
+

Import data

+

Map columns, preview rows, then run a dry-run before importing.

+
+
Loading import wizard…
+
+ 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/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/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/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/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/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_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/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/orm/model_module.go b/core/orm/model_module.go index c0eb2cca..ecd7a181 100644 --- a/core/orm/model_module.go +++ b/core/orm/model_module.go @@ -9,7 +9,7 @@ import ( // PlatformModules materialize on a fresh DB and cannot be uninstalled. var PlatformModules = map[string]bool{ "base": true, - "mail": true, + "im": true, } // IsPlatformModule reports whether name is part of the always-on platform spine. diff --git a/core/orm/schema_indexes.go b/core/orm/schema_indexes.go index dc0e811b..661b63b2 100644 --- a/core/orm/schema_indexes.go +++ b/core/orm/schema_indexes.go @@ -10,41 +10,9 @@ func ensureExtraIndexes(ctx context.Context) error { if DB == nil { return nil } - if err := ensureMailMessageListIndex(ctx); err != nil { - return err - } return ensureSysTranslationUniqueIndex(ctx) } -func ensureMailMessageListIndex(ctx context.Context) error { - tablePhysical := MustModelToTableName("mail.message") - if tablePhysical == "" { - return nil - } - ok, err := tableExists(ctx, tablePhysical) - if err != nil || !ok { - return err - } - tableQuoted := MustQuotedTableName("mail.message") - modelCol, err := QuotedColumnForModel("mail.message", "model") - if err != nil { - return err - } - coreCol, err := QuotedColumnForModel("mail.message", "core_id") - if err != nil { - return err - } - dateCol, err := QuotedColumnForModel("mail.message", "create_date") - if err != nil { - return err - } - idxName := "idx_" + tablePhysical + "_model_core_created" - q := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s, %s, %s DESC)", - quoteIdent(idxName), tableQuoted, modelCol, coreCol, dateCol) - _, err = DB.ExecContext(ctx, q) - return err -} - func ensureSysTranslationUniqueIndex(ctx context.Context) error { tablePhysical := MustModelToTableName("sys.translation") if tablePhysical == "" { diff --git a/core/orm/setup_sync.go b/core/orm/setup_sync.go index a789d384..d07428a0 100644 --- a/core/orm/setup_sync.go +++ b/core/orm/setup_sync.go @@ -14,7 +14,6 @@ var InitialSetupModelNames = []string{ "core.group", "core.partner", "core.user", - "mail.message", "sys.access", "sys.action.window", "sys.action.url", diff --git a/core/orm/testexports.go b/core/orm/testexports.go index 00910679..26730859 100644 --- a/core/orm/testexports.go +++ b/core/orm/testexports.go @@ -34,6 +34,19 @@ func CoerceFieldValueForTest(fieldDef FieldDefinition, v interface{}) (interface return coerceFieldValue(fieldDef, v) } +// FormatAuditLogBodyForTest exposes audit log formatting for tests. +func FormatAuditLogBodyForTest(ctx context.Context, model, action, beforeJSON, afterJSON, detail string) string { + 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/report/bulk_dryrun.go b/core/report/bulk_dryrun.go new file mode 100644 index 00000000..2abe6b80 --- /dev/null +++ b/core/report/bulk_dryrun.go @@ -0,0 +1,79 @@ +package report + +import ( + "context" + "encoding/json" + "fmt" + + "sumeru/core/orm" +) + +// ImportMessage is one row/column validation or simulation message. +type ImportMessage struct { + Type string `json:"type"` + Message string `json:"message"` + Row int `json:"row,omitempty"` +} + +// DryRunBulkImport validates every row and simulates create/update counts without writes. +func DryRunBulkImport(ctx context.Context, in PreviewBulkImportInput) (PreviewResult, []ImportMessage, error) { + batch, data, _, err := loadBatchCSV(ctx, in.BatchID) + if err != nil { + return PreviewResult{}, nil, err + } + targetModel := orm.AsString(batch["target_model"]) + modelInst, ok := orm.Registry[targetModel] + if !ok { + return PreviewResult{}, nil, fmt.Errorf("unknown model %q", targetModel) + } + headers, rows, err := parseCSV(data) + if err != nil { + return PreviewResult{}, nil, err + } + mapping := in.Mapping + if len(mapping) == 0 { + _ = json.Unmarshal([]byte(orm.AsString(batch["column_mapping"])), &mapping) + } + mode := orm.AsString(batch["import_mode"]) + allowed := allowedFieldNames(modelInst) + result := PreviewResult{TotalRows: len(rows)} + var messages []ImportMessage + var wouldCreate, wouldUpdate int + + for i, record := range rows { + vals := rowValuesFromMapping(headers, record, mapping) + if len(vals) == 0 { + continue + } + coerced, coerceErrs := coerceImportValues(ctx, modelInst, vals) + for _, e := range coerceErrs { + messages = append(messages, ImportMessage{Type: "error", Message: e, Row: i + 1}) + result.ErrorCount++ + } + vals = coerced + errs := validateRowValues(modelInst, vals, allowed, mode) + for _, e := range errs { + messages = append(messages, ImportMessage{Type: "error", Message: e, Row: i + 1}) + result.ErrorCount++ + } + if len(errs) > 0 || len(coerceErrs) > 0 { + result.BlockingErr = true + continue + } + if mode == ImportModeUpsert { + if idRaw, ok := vals["id"]; ok { + if id, ok := orm.CoerceInt64(idRaw); ok && id > 0 { + if _, err := orm.SearchOne(ctx, targetModel, map[string]interface{}{"id": int(id)}); err == nil { + wouldUpdate++ + continue + } + } + } + } + wouldCreate++ + } + result.Rows = nil + summary := fmt.Sprintf("Would create %d, update %d", wouldCreate, wouldUpdate) + messages = append([]ImportMessage{{Type: "info", Message: summary}}, messages...) + return result, messages, nil +} diff --git a/core/report/bulk_import.go b/core/report/bulk_import.go index caa51bfa..6d7fd02f 100644 --- a/core/report/bulk_import.go +++ b/core/report/bulk_import.go @@ -45,7 +45,10 @@ func ExecuteBulkImport(ctx context.Context, in ExecuteBulkImportInput) (ImportRe result.Skipped++ continue } - errs := validateRowValues(modelInst, vals, allowed, mode) + coerced, coerceErrs := coerceImportValues(ctx, modelInst, vals) + vals = coerced + errs := append([]string{}, coerceErrs...) + errs = append(errs, validateRowValues(modelInst, vals, allowed, mode)...) if len(errs) > 0 { if in.SkipInvalid || in.ImportValidOnly { result.Skipped++ diff --git a/core/report/bulk_job.go b/core/report/bulk_job.go new file mode 100644 index 00000000..a0212e76 --- /dev/null +++ b/core/report/bulk_job.go @@ -0,0 +1,148 @@ +package report + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "sumeru/core/orm" + "sumeru/core/scheduler" +) + +func init() { + scheduler.RegisterCronHandler(BulkJobCronCode, runBulkJobCron) +} + +// RunBulkJobCronForTest executes one queued bulk job (tests only). +func RunBulkJobCronForTest(ctx context.Context) error { + return runBulkJobCron(ctx, nil) +} + +func runBulkJobCron(ctx context.Context, _ map[string]interface{}) error { + batch, err := orm.SearchOne(ctx, BulkModelName, map[string]interface{}{"state": BulkStateQueued}) + if err != nil || len(batch) == 0 { + return nil + } + id, _ := orm.CoerceInt64(batch["id"]) + if id <= 0 { + return nil + } + _ = orm.UpdateRecordByID(ctx, BulkModelName, int(id), map[string]interface{}{"state": BulkStateRunning}) + direction := strings.TrimSpace(orm.AsString(batch["direction"])) + if direction == DirectionExport { + err = runExportJob(ctx, batch) + } else { + mapping := map[string]string{} + _ = json.Unmarshal([]byte(orm.AsString(batch["column_mapping"])), &mapping) + _, err = ExecuteBulkImport(ctx, ExecuteBulkImportInput{ + BatchID: int(id), + Mapping: mapping, + SkipInvalid: true, + }) + } + if err != nil { + _ = orm.UpdateRecordByID(ctx, BulkModelName, int(id), map[string]interface{}{ + "state": BulkStateFailed, + "result_summary": err.Error(), + }) + return err + } + if direction != DirectionExport { + _ = orm.UpdateRecordByID(ctx, BulkModelName, int(id), map[string]interface{}{ + "state": BulkStateDone, + "result_summary": "Import finished", + }) + } + return nil +} + +// RunExportJobForTest runs export job logic for external tests. +func RunExportJobForTest(ctx context.Context, batch map[string]interface{}) error { + return runExportJob(ctx, batch) +} + +func runExportJob(ctx context.Context, batch map[string]interface{}) error { + modelName := strings.TrimSpace(orm.AsString(batch["target_model"])) + format := strings.TrimSpace(orm.AsString(batch["export_format"])) + if format == "" { + format = "csv" + } + var domain [][]interface{} + _ = json.Unmarshal([]byte(orm.AsString(batch["export_domain"])), &domain) + fields := ParseFieldsParam(orm.AsString(batch["selected_fields"])) + in := ExportCSVInput{Model: modelName, Fields: fields, Domain: domain} + var data []byte + var err error + data, err = ExportCSVUnlimited(ctx, in) + if format == "xlsx" { + fields, _ := ValidateFields(modelName, fields) + rows, _ := FetchRowsUnlimited(ctx, modelName, domain, 0) + var table [][]string + for _, row := range rows { + line := make([]string, len(fields)) + for i, f := range fields { + line[i] = formatCell(ctx, modelName, f, row[f]) + } + table = append(table, line) + } + data, err = ExportXLSXForTest(fields, table) + } + if err != nil { + return err + } + batchID, _ := orm.CoerceInt64(batch["id"]) + name := fmt.Sprintf("export_%s.%s", modelName, format) + attID, err := orm.CreateBinaryAttachment(ctx, orm.CreateBinaryAttachmentInput{ + Name: name, + ResModel: BulkModelName, + ResID: int(batchID), + Data: data, + Mimetype: exportMime(format), + }) + if err != nil { + return err + } + return orm.UpdateRecordByID(ctx, BulkModelName, int(batchID), map[string]interface{}{ + "state": BulkStateDone, + "attachment_id": attID, + "result_summary": fmt.Sprintf("Export ready (%s)", name), + }) +} + +func exportMime(format string) string { + if format == "xlsx" { + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + return "text/csv" +} + +// QueueExportJob creates an async export batch. +func QueueExportJob(ctx context.Context, modelName string, fields []string, domain [][]interface{}, format string, uid int) (int, error) { + fieldsJSON, _ := json.Marshal(fields) + domainJSON, _ := json.Marshal(domain) + inst, ok := orm.Registry[BulkModelName] + if !ok { + return 0, fmt.Errorf("bulk import model missing") + } + return orm.Create(ctx, inst, map[string]interface{}{ + "name": fmt.Sprintf("Export %s", modelName), + "target_model": modelName, + "direction": DirectionExport, + "export_format": format, + "export_domain": string(domainJSON), + "selected_fields": string(fieldsJSON), + "user_id": uid, + "state": BulkStateQueued, + }) +} + +// CountBatchRows returns CSV row count for a batch. +func CountBatchRows(ctx context.Context, batchID int) (int, error) { + _, data, _, err := loadBatchCSV(ctx, batchID) + if err != nil { + return 0, err + } + _, rows, err := parseCSV(data) + return len(rows), err +} diff --git a/core/report/bulk_preview.go b/core/report/bulk_preview.go index 8461886a..a39c8dd0 100644 --- a/core/report/bulk_preview.go +++ b/core/report/bulk_preview.go @@ -36,13 +36,17 @@ func PreviewBulkImport(ctx context.Context, in PreviewBulkImportInput) (PreviewR } allowed := allowedFieldNames(modelInst) result := PreviewResult{TotalRows: len(rows)} - limit := 10 + limit := maxPreviewRows if len(rows) < limit { limit = len(rows) } for i := 0; i < limit; i++ { vals := rowValuesFromMapping(headers, rows[i], mapping) - errs := validateRowValues(modelInst, vals, allowed, orm.AsString(batch["import_mode"])) + coerced, coerceErrs := coerceImportValues(ctx, modelInst, vals) + vals = coerced + var errs []string + errs = append(errs, coerceErrs...) + errs = append(errs, validateRowValues(modelInst, vals, allowed, orm.AsString(batch["import_mode"]))...) if len(errs) > 0 { result.ErrorCount += len(errs) result.BlockingErr = true diff --git a/core/report/bulk_staging.go b/core/report/bulk_staging.go index 00b27435..baf90621 100644 --- a/core/report/bulk_staging.go +++ b/core/report/bulk_staging.go @@ -38,18 +38,22 @@ func CreateBatch(ctx context.Context, in CreateBatchInput) (batchID int, err err fieldsJSON, _ := json.Marshal(in.SelectedFields) headersJSON, _ := json.Marshal(headers) - attID, err := orm.Create(ctx, orm.Registry["sys.attachment"], map[string]interface{}{ - "name": fmt.Sprintf("bulk_%s.csv", time.Now().Format("20060102_150405")), - "model": BulkModelName, - "mimetype": "text/csv", - "file_size": len(in.CSVContent), - "datas": base64.StdEncoding.EncodeToString(in.CSVContent), + attID, err := orm.CreateBinaryAttachment(ctx, orm.CreateBinaryAttachmentInput{ + Name: fmt.Sprintf("bulk_%s.csv", time.Now().Format("20060102_150405")), + ResModel: BulkModelName, + Data: in.CSVContent, + Mimetype: "text/csv", }) if err != nil { return 0, fmt.Errorf("stage attachment: %w", err) } mapping := defaultColumnMapping(headers, in.SelectedFields) + if tpl := ApplyImportTemplateMapping(ctx, in.TargetModel, headers, in.UserID); len(tpl) > 0 { + for col, field := range tpl { + mapping[col] = field + } + } mappingJSON, _ := json.Marshal(mapping) batchID, err = orm.Create(ctx, orm.Registry[BulkModelName], map[string]interface{}{ @@ -63,7 +67,8 @@ func CreateBatch(ctx context.Context, in CreateBatchInput) (batchID int, err err "next_url": in.NextURL, "user_id": in.UserID, "action_id": in.ActionID, - "state": "draft", + "state": BulkStateDraft, + "direction": DirectionImport, }) if err != nil { return 0, err @@ -108,10 +113,18 @@ func loadBatchCSV(ctx context.Context, batchID int) (map[string]interface{}, []b if err != nil { return nil, nil, nil, fmt.Errorf("attachment not found") } - raw := orm.AsString(att["datas"]) - data, err := base64.StdEncoding.DecodeString(raw) - if err != nil { - data = []byte(raw) + var data []byte + if store := orm.AsString(att["store_fname"]); store != "" { + data, err = orm.ReadAttachment(ctx, store) + if err != nil { + return nil, nil, nil, fmt.Errorf("read staged file: %w", err) + } + } else { + raw := orm.AsString(att["datas"]) + data, err = base64.StdEncoding.DecodeString(raw) + if err != nil { + data = []byte(raw) + } } mapping := map[string]string{} _ = json.Unmarshal([]byte(orm.AsString(batch["column_mapping"])), &mapping) 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/report/exchange.go b/core/report/exchange.go index 3fadd0ad..42da2ed3 100644 --- a/core/report/exchange.go +++ b/core/report/exchange.go @@ -32,6 +32,38 @@ func FetchRows(ctx context.Context, modelName string, domain [][]interface{}, re return rows, nil } +// FetchRowsUnlimitedForTest exposes paged export fetch for external tests. +func FetchRowsUnlimitedForTest(ctx context.Context, modelName string, domain [][]interface{}, recordID int) ([]map[string]interface{}, error) { + return FetchRowsUnlimited(ctx, modelName, domain, recordID) +} + +// FetchRowsUnlimited loads all rows matching domain (paged) for async export. +func FetchRowsUnlimited(ctx context.Context, modelName string, domain [][]interface{}, recordID int) ([]map[string]interface{}, error) { + if recordID > 0 { + return FetchRows(ctx, modelName, domain, recordID) + } + const page = 500 + var all []map[string]interface{} + offset := 0 + for { + chunk, err := orm.SearchPage(ctx, modelName, domain, page, offset, "id ASC") + if err != nil { + return nil, err + } + if len(chunk) == 0 { + break + } + uid := orm.SecurityUID(ctx) + orm.RedactSearchResults(ctx, uid, modelName, chunk) + all = append(all, chunk...) + if len(chunk) < page { + break + } + offset += len(chunk) + } + return all, nil +} + // ValidateFields filters field names against model metadata. func ValidateFields(modelName string, fields []string) ([]string, error) { modelInst, ok := orm.Registry[modelName] diff --git a/core/report/export_csv.go b/core/report/export_csv.go index 132813f0..c31457f3 100644 --- a/core/report/export_csv.go +++ b/core/report/export_csv.go @@ -39,6 +39,32 @@ func ExportCSV(ctx context.Context, in ExportCSVInput) ([]byte, error) { return writeCSV(fields, data) } +// ExportCSVUnlimitedForTest exposes unlimited CSV export for external tests. +func ExportCSVUnlimitedForTest(ctx context.Context, in ExportCSVInput) ([]byte, error) { + return ExportCSVUnlimited(ctx, in) +} + +// ExportCSVUnlimited exports all matching rows (no 500 cap). +func ExportCSVUnlimited(ctx context.Context, in ExportCSVInput) ([]byte, error) { + fields, err := ValidateFields(in.Model, in.Fields) + if err != nil { + return nil, err + } + rows, err := FetchRowsUnlimited(ctx, in.Model, in.Domain, in.RecordID) + if err != nil { + return nil, err + } + var data [][]string + for _, row := range rows { + line := make([]string, len(fields)) + for i, f := range fields { + line[i] = formatCell(ctx, in.Model, f, row[f]) + } + data = append(data, line) + } + return writeCSV(fields, data) +} + // BulkTemplateCSV returns header-only CSV for selected fields. func BulkTemplateCSV(modelName string, fields []string) ([]byte, error) { fields, err := ValidateFields(modelName, fields) diff --git a/core/report/import_coerce.go b/core/report/import_coerce.go new file mode 100644 index 00000000..162a911b --- /dev/null +++ b/core/report/import_coerce.go @@ -0,0 +1,148 @@ +package report + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "sumeru/core/orm" +) + +// CoerceImportValuesForTest exposes import coercion for external tests. +func CoerceImportValuesForTest(ctx context.Context, modelInst orm.Model, vals map[string]interface{}) (map[string]interface{}, []string) { + return coerceImportValues(ctx, modelInst, vals) +} + +func coerceImportValues(ctx context.Context, modelInst orm.Model, vals map[string]interface{}) (map[string]interface{}, []string) { + fieldMap := map[string]orm.FieldDefinition{} + for _, f := range modelInst.Fields() { + fieldMap[f.Name] = f + } + out := map[string]interface{}{} + var errs []string + for name, raw := range vals { + def, ok := fieldMap[name] + if !ok { + out[name] = raw + continue + } + coerced, err := coerceFieldValue(ctx, def, raw) + if err != nil { + errs = append(errs, fmt.Sprintf("%s: %s", name, err.Error())) + continue + } + out[name] = coerced + } + return out, errs +} + +func coerceFieldValue(ctx context.Context, def orm.FieldDefinition, raw interface{}) (interface{}, error) { + switch def.Type { + case orm.Boolean: + switch v := raw.(type) { + case bool: + return v, nil + case string: + s := strings.ToLower(strings.TrimSpace(v)) + if s == "1" || s == "true" || s == "yes" { + return true, nil + } + if s == "0" || s == "false" || s == "no" || s == "" { + return false, nil + } + } + case orm.Integer: + switch v := raw.(type) { + case int64: + return v, nil + case int: + return int64(v), nil + case string: + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid integer") + } + return n, nil + } + case orm.Float, orm.Float64, orm.Numeric: + switch v := raw.(type) { + case float64: + return v, nil + case int64: + return float64(v), nil + case string: + n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return nil, fmt.Errorf("invalid number") + } + return n, nil + } + case orm.Date: + if s, ok := raw.(string); ok { + t, err := time.Parse("2006-01-02", strings.TrimSpace(s)) + if err != nil { + return nil, fmt.Errorf("invalid date") + } + return t, nil + } + case orm.DateTime: + if s, ok := raw.(string); ok { + s = strings.TrimSpace(s) + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + if t, err := time.Parse(layout, s); err == nil { + return t, nil + } + } + return nil, fmt.Errorf("invalid datetime") + } + case orm.Selection: + if s, ok := raw.(string); ok { + s = strings.TrimSpace(s) + for _, opt := range def.Selection { + if len(opt) >= 2 && (opt[0] == s || opt[1] == s) { + return opt[0], nil + } + } + return nil, fmt.Errorf("invalid selection") + } + case orm.Many2One: + return resolveMany2One(ctx, def.Relation, raw) + } + return raw, nil +} + +func resolveMany2One(ctx context.Context, relation string, raw interface{}) (interface{}, error) { + relation = strings.TrimSpace(relation) + if relation == "" { + return raw, nil + } + switch v := raw.(type) { + case int64: + if v <= 0 { + return nil, fmt.Errorf("invalid reference") + } + return v, nil + case int: + if v <= 0 { + return nil, fmt.Errorf("invalid reference") + } + return int64(v), nil + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil, nil + } + if id, err := strconv.ParseInt(s, 10, 64); err == nil && id > 0 { + return id, nil + } + row, err := orm.SearchOne(ctx, relation, map[string]interface{}{"name": s}) + if err != nil || len(row) == 0 { + return nil, fmt.Errorf("no %s named %q", relation, s) + } + id, _ := orm.CoerceInt64(row["id"]) + return id, nil + } + return raw, nil +} diff --git a/core/report/import_spreadsheet.go b/core/report/import_spreadsheet.go new file mode 100644 index 00000000..66c49240 --- /dev/null +++ b/core/report/import_spreadsheet.go @@ -0,0 +1,174 @@ +package report + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "strings" +) + +// ParseUploadContentForTest exposes upload normalization for external tests. +func ParseUploadContentForTest(filename string, content []byte) ([]byte, error) { + return ParseUploadContent(filename, content) +} + +// ParseUploadContent normalizes CSV or first-sheet XLSX into CSV bytes. +func ParseUploadContent(filename string, content []byte) ([]byte, error) { + name := strings.ToLower(strings.TrimSpace(filename)) + if strings.HasSuffix(name, ".xlsx") { + return xlsxFirstSheetCSV(content) + } + return content, nil +} + +func xlsxFirstSheetCSV(content []byte) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return nil, fmt.Errorf("xlsx: %w", err) + } + var shared []string + var sheetXML []byte + for _, f := range zr.File { + switch f.Name { + case "xl/sharedStrings.xml": + data, err := readZipEntry(f) + if err != nil { + return nil, err + } + shared = parseSharedStrings(data) + case "xl/worksheets/sheet1.xml": + sheetXML, err = readZipEntry(f) + if err != nil { + return nil, err + } + } + } + if len(sheetXML) == 0 { + return nil, fmt.Errorf("xlsx: sheet1 missing") + } + rows := parseSheetRows(sheetXML, shared) + if len(rows) == 0 { + return nil, fmt.Errorf("xlsx: empty sheet") + } + return writeCSV(rows[0], rows[1:]) +} + +func readZipEntry(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) +} + +func parseSharedStrings(data []byte) []string { + dec := xml.NewDecoder(bytes.NewReader(data)) + var out []string + var cur strings.Builder + inSI := false + inT := false + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return out + } + switch el := tok.(type) { + case xml.StartElement: + if el.Name.Local == "si" { + inSI = true + cur.Reset() + } + if inSI && el.Name.Local == "t" { + inT = true + } + case xml.CharData: + if inT { + cur.Write(el) + } + case xml.EndElement: + if el.Name.Local == "t" { + inT = false + } + if el.Name.Local == "si" { + out = append(out, cur.String()) + inSI = false + } + } + } + return out +} + +func parseSheetRows(data []byte, shared []string) [][]string { + dec := xml.NewDecoder(bytes.NewReader(data)) + var rows [][]string + var row []string + inRow := false + inValue := false + cellType := "" + var val strings.Builder + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return rows + } + switch el := tok.(type) { + case xml.StartElement: + switch el.Name.Local { + case "row": + inRow = true + row = nil + case "c": + cellType = "" + for _, a := range el.Attr { + if a.Name.Local == "t" { + cellType = a.Value + } + } + val.Reset() + case "v": + inValue = true + } + case xml.CharData: + if inValue { + val.Write(el) + } + case xml.EndElement: + switch el.Name.Local { + case "v": + inValue = false + text := val.String() + if cellType == "s" { + if idx, err := parseInt(text); err == nil && idx >= 0 && idx < len(shared) { + text = shared[idx] + } + } + row = append(row, text) + case "row": + if inRow && len(row) > 0 { + rows = append(rows, row) + } + inRow = false + } + } + } + return rows +} + +func parseInt(s string) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty") + } + var n int + _, err := fmt.Sscanf(s, "%d", &n) + return n, err +} diff --git a/core/report/import_template.go b/core/report/import_template.go new file mode 100644 index 00000000..3cead4d5 --- /dev/null +++ b/core/report/import_template.go @@ -0,0 +1,64 @@ +package report + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "sumeru/core/orm" +) + +const importTemplateModel = "sys.import.template" + +// ApplyImportTemplateMapping merges saved template mapping when headers match. +func ApplyImportTemplateMapping(ctx context.Context, targetModel string, headers []string, uid int) map[string]string { + targetModel = strings.TrimSpace(targetModel) + if targetModel == "" { + return nil + } + domain := [][]interface{}{{"target_model", "=", targetModel}} + if uid > 0 { + domain = append(domain, []interface{}{"user_id", "=", uid}) + } + rows, err := orm.SearchPage(ctx, importTemplateModel, domain, 5, 0, "id DESC") + if err != nil || len(rows) == 0 { + domain = [][]interface{}{{"target_model", "=", targetModel}, {"shared", "=", true}} + rows, err = orm.SearchPage(ctx, importTemplateModel, domain, 5, 0, "id DESC") + } + if err != nil || len(rows) == 0 { + return nil + } + var mapping map[string]string + _ = json.Unmarshal([]byte(orm.AsString(rows[0]["column_mapping"])), &mapping) + if len(mapping) == 0 { + return nil + } + out := map[string]string{} + for _, h := range headers { + if f, ok := mapping[h]; ok && strings.TrimSpace(f) != "" { + out[h] = f + } + } + return out +} + +// SaveImportTemplate persists mapping for reuse. +func SaveImportTemplate(ctx context.Context, name, targetModel, importMode, selectedFields, columnMapping string, uid int, shared bool) (int, error) { + inst, ok := orm.Registry[importTemplateModel] + if !ok { + return 0, fmt.Errorf("import template model missing") + } + vals := map[string]interface{}{ + "name": strings.TrimSpace(name), + "target_model": targetModel, + "import_mode": importMode, + "selected_fields": selectedFields, + "column_mapping": columnMapping, + "shared": shared, + } + if uid > 0 { + vals["user_id"] = uid + } + return orm.Create(ctx, inst, vals) +} diff --git a/core/report/register.go b/core/report/register.go index 36d9b4bf..da525794 100644 --- a/core/report/register.go +++ b/core/report/register.go @@ -2,6 +2,7 @@ package report import ( "context" + "encoding/json" "fmt" "strconv" "strings" @@ -27,6 +28,23 @@ func actionConfirmImport(ctx context.Context, model string, id int, vals map[str mapping, _ = ParseMappingJSON(orm.AsString(batch["column_mapping"])) } skipInvalid := vals["skip_invalid"] == "1" || vals["skip_invalid"] == "true" + rowCount, _ := CountBatchRows(ctx, id) + if rowCount > asyncImportRowThreshold { + _ = orm.UpdateRecordByID(ctx, BulkModelName, id, map[string]interface{}{ + "state": BulkStateQueued, + "total_rows": rowCount, + "column_mapping": orm.AsString(batch["column_mapping"]), + }) + if len(mapping) > 0 { + mappingJSON, _ := json.Marshal(mapping) + _ = orm.UpdateRecordByID(ctx, BulkModelName, id, map[string]interface{}{"column_mapping": string(mappingJSON)}) + } + next := orm.AsString(batch["next_url"]) + if next == "" { + next = "/web/home" + } + return next + "?msg=Import+queued+for+background+processing", nil + } result, err := ExecuteBulkImport(ctx, ExecuteBulkImportInput{ BatchID: id, Mapping: mapping, diff --git a/core/report/types.go b/core/report/types.go index cea845d7..e4c47c20 100644 --- a/core/report/types.go +++ b/core/report/types.go @@ -11,7 +11,22 @@ const ( BulkModelName = "sys.bulk.import" - maxExportRows = 500 + DirectionImport = "import" + DirectionExport = "export" + + BulkStateDraft = "draft" + BulkStateQueued = "queued" + BulkStateRunning = "running" + BulkStateDone = "done" + BulkStateFailed = "failed" + BulkStateCancelled = "cancelled" + + BulkJobCronCode = "platform.bulk_job" + + maxExportRows = 500 + maxSyncExportRows = 500 + maxPreviewRows = 50 + asyncImportRowThreshold = 500 ) // Capabilities describes report download and bulk upload enabled on a view. 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/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/bulk_handlers.go b/core/server/web/bulk_handlers.go index c96bca68..e49ab7ff 100644 --- a/core/server/web/bulk_handlers.go +++ b/core/server/web/bulk_handlers.go @@ -1,8 +1,10 @@ package web import ( + "encoding/json" "io" "net/http" + "strconv" "strings" "sumeru/core/orm" @@ -22,7 +24,7 @@ func BulkUploadHandler(w http.ResponseWriter, r *http.Request) { if !requireModelAccess(w, r, modelName, "create") { return } - upload, _, err := r.FormFile(importFileField) + upload, fileHeader, err := r.FormFile(importFileField) if err != nil { http.Error(w, "file required", http.StatusBadRequest) return @@ -33,6 +35,15 @@ func BulkUploadHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "empty file", http.StatusBadRequest) return } + filename := "" + if fileHeader != nil { + filename = fileHeader.Filename + } + content, err = report.ParseUploadContent(filename, content) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } if err := orm.ValidateAttachmentMIME("", content); err != nil { http.Error(w, "file type not allowed", http.StatusBadRequest) return @@ -57,8 +68,112 @@ func BulkUploadHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - actionID = report.BulkImportFormActionID(r.Context()) - http.Redirect(w, r, report.MappingFormURL(batchID, actionID), http.StatusSeeOther) + http.Redirect(w, r, importWizardURL(batchID), http.StatusSeeOther) +} + +func importWizardURL(batchID int) string { + return "/web/import?batch=" + strconv.Itoa(batchID) +} + +// BulkPreviewHandler POST JSON preview for a staged batch. +func BulkPreviewHandler(w http.ResponseWriter, r *http.Request) { + if !requireLoginJSONPost(w, r) { + return + } + var body struct { + BatchID int `json:"batch_id"` + Mapping map[string]string `json:"column_mapping"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.BatchID <= 0 { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + out, err := report.PreviewBulkImport(r.Context(), report.PreviewBulkImportInput{ + BatchID: body.BatchID, + Mapping: body.Mapping, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, out) +} + +// BulkSaveImportTemplateHandler POST JSON — persist column mapping as sys.import.template. +func BulkSaveImportTemplateHandler(w http.ResponseWriter, r *http.Request) { + if !requireLoginJSONPost(w, r) { + return + } + var body struct { + Name string `json:"name"` + BatchID int `json:"batch_id"` + Mapping map[string]string `json:"column_mapping"` + Shared bool `json:"shared"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.BatchID <= 0 { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + batch, err := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": body.BatchID}) + if err != nil || len(batch) == 0 { + http.NotFound(w, r) + return + } + targetModel := orm.AsString(batch["target_model"]) + if !requireModelAccess(w, r, targetModel, "create") { + return + } + mapping := body.Mapping + if len(mapping) == 0 { + mapping, err = report.ParseMappingJSON(orm.AsString(batch["column_mapping"])) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + mappingJSON, err := json.Marshal(mapping) + if err != nil { + http.Error(w, "mapping encode failed", http.StatusBadRequest) + return + } + selected := orm.AsString(batch["selected_fields"]) + importMode := orm.AsString(batch["import_mode"]) + uid := AuthenticatedUserID(r) + templateID, err := report.SaveImportTemplate(r.Context(), name, targetModel, importMode, selected, string(mappingJSON), uid, body.Shared) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, map[string]interface{}{"template_id": templateID}) +} + +// BulkDryRunHandler POST JSON dry-run validation for entire file. +func BulkDryRunHandler(w http.ResponseWriter, r *http.Request) { + if !requireLoginJSONPost(w, r) { + return + } + var body struct { + BatchID int `json:"batch_id"` + Mapping map[string]string `json:"column_mapping"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.BatchID <= 0 { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + preview, messages, err := report.DryRunBulkImport(r.Context(), report.PreviewBulkImportInput{ + BatchID: body.BatchID, + Mapping: body.Mapping, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, map[string]interface{}{"preview": preview, "messages": messages}) } // BulkConfirmHandler POST /web/bulk/confirm — run import after mapping (non-object-action path). 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/server/web/csrf.go b/core/server/web/csrf.go index 8d1b7faa..e7c6cb30 100644 --- a/core/server/web/csrf.go +++ b/core/server/web/csrf.go @@ -72,7 +72,7 @@ func CSRFTokenForRequest(r *http.Request) string { return hex.EncodeToString(mac.Sum(nil)[:16]) } -// ValidateCSRF checks the csrf_token form field or X-CSRF-Token header against the session-bound token. +// ValidateCSRF checks csrf_token (form, header, or query on GET/HEAD) against the session-bound token. func ValidateCSRF(r *http.Request) bool { expected := CSRFTokenForRequest(r) if expected == "" { @@ -82,6 +82,9 @@ func ValidateCSRF(r *http.Request) bool { if got == "" { got = r.Header.Get(csrfHeaderName) } + if got == "" && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + got = strings.TrimSpace(r.URL.Query().Get(csrfFormField)) + } return got != "" && hmac.Equal([]byte(got), []byte(expected)) } diff --git a/core/server/web/export_handlers.go b/core/server/web/export_handlers.go index 3412adc7..d6f523b4 100644 --- a/core/server/web/export_handlers.go +++ b/core/server/web/export_handlers.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "sumeru/core/orm" "sumeru/core/report" ) @@ -30,7 +29,7 @@ func resolveExportRequest(w http.ResponseWriter, r *http.Request) (report.Export http.Error(w, "fields required", http.StatusBadRequest) return report.ExportCSVInput{}, false } - domain := exportDomain(r) + domain := listExportDomain(r.Context(), r, modelName) recordID, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(workspaceRecordIDParam))) return report.ExportCSVInput{ Model: modelName, @@ -40,16 +39,26 @@ func resolveExportRequest(w http.ResponseWriter, r *http.Request) (report.Export }, true } -func exportDomain(r *http.Request) [][]interface{} { - actionID := report.ParseActionIDParam(r.URL.Query().Get(actionIDField)) - if actionID <= 0 { - return nil +func listExportDomain(ctx context.Context, r *http.Request, modelName string) [][]interface{} { + q := r.URL.Query() + actionID := report.ParseActionIDParam(q.Get(actionIDField)) + var actionData map[string]interface{} + if actionID > 0 { + if row, err := loadWindowAction(ctx, actionID); err == nil { + actionData = row + } } - actionData, err := orm.SearchOne(r.Context(), "sys.action.window", map[string]interface{}{"id": actionID}) - if err != nil { - return nil + if actionData == nil { + actionData = map[string]interface{}{} } - return actionListDomain(r.Context(), actionData) + searchView := loadSearchViewForAction(ctx, modelName, actionData) + return workspaceListDomain(ctx, listDomainInput{ + ActionData: actionData, + SearchView: searchView, + SearchQuery: strings.TrimSpace(q.Get(workspaceListSearchParam)), + FilterCSV: strings.TrimSpace(q.Get(workspaceFilterParam)), + DomainJSON: strings.TrimSpace(q.Get(workspaceDomainParam)), + }) } // analyticsExportDomain merges action domain with workspace search/filter/domain query params. @@ -176,6 +185,37 @@ func ExportTemplatePDFHandler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +// ExportAsyncHandler POST — queue large export as background job. +func ExportAsyncHandler(w http.ResponseWriter, r *http.Request) { + if !RequirePOST(w, r) || !requireLogin(w, r) || !validateSessionCSRF(w, r) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + modelName := strings.TrimSpace(r.FormValue(importModelField)) + if modelName == "" { + http.Error(w, "model required", http.StatusBadRequest) + return + } + if !requireModelAccess(w, r, modelName, "read") { + return + } + fields := report.ParseFieldsParam(r.FormValue(reportFieldsParam)) + domain := listExportDomain(r.Context(), r, modelName) + format := strings.TrimSpace(r.FormValue("format")) + if format == "" { + format = "csv" + } + jobID, err := report.QueueExportJob(r.Context(), modelName, fields, domain, format, AuthenticatedUserID(r)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, map[string]interface{}{"jobId": jobID, "state": report.BulkStateQueued}) +} + func BulkTemplateHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return diff --git a/core/server/web/import_wizard_page.go b/core/server/web/import_wizard_page.go new file mode 100644 index 00000000..eb75b4e1 --- /dev/null +++ b/core/server/web/import_wizard_page.go @@ -0,0 +1,34 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + + "sumeru/core/engine/render" +) + +// ImportWizardPageHandler GET /web/import?batch= — shell page for SWC import wizard. +func ImportWizardPageHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + batchID := strings.TrimSpace(r.URL.Query().Get("batch")) + if batchID == "" { + http.Error(w, "batch required", http.StatusBadRequest) + return + } + if _, err := strconv.Atoi(batchID); err != nil { + http.Error(w, "invalid batch", http.StatusBadRequest) + return + } + renderShellPage(w, r, shellPageOpts{ + Route: importWizardRoute, + InnerTemplate: render.TemplatePagesImportWizard, + InnerData: map[string]interface{}{ + "BatchID": batchID, + "CSRFToken": CSRFTokenForRequest(r), + }, + Page: render.PageData{Title: "Import data"}, + }) +} diff --git a/core/server/web/record_chatter_post.go b/core/server/web/record_chatter_post.go deleted file mode 100644 index 5e3f18fd..00000000 --- a/core/server/web/record_chatter_post.go +++ /dev/null @@ -1,108 +0,0 @@ -package web - -import ( - "context" - "fmt" - "net/http" - "strconv" - "strings" - "unicode/utf8" - - "sumeru/addons/mail" - "sumeru/core/orm" -) - -// ChatterPostHandler accepts POST /web/chatter/post (model, res_id, body, next). -func ChatterPostHandler(w http.ResponseWriter, r *http.Request) { - if !requireLoginAndPOST(w, r) { - return - } - if !mail.CompanyChatterEnabled(r.Context()) { - redirectRecordError(w, r, SafeWebNext(r.PostFormValue(nextField), homeRoute), "record_chatter", "", fmt.Errorf("chatter disabled")) - return - } - - form := parseChatterPostForm(r) - if form.Body == "" { - redirectToWebNext(w, r, form.Next) - return - } - - recordID, err := validateChatterPost(r, form) - if err != nil { - redirectRecordError(w, r, form.Next, "record_chatter", form.ModelName, err) - return - } - - if err := postChatterComment(r.Context(), form.ModelName, recordID, form.Body); err != nil { - redirectRecordError(w, r, form.Next, "record_chatter", form.ModelName, err) - return - } - - redirectToWebNext(w, r, form.Next) -} - -type chatterPostForm struct { - ModelName string - RecordIDRaw string - Body string - Next string -} - -func parseChatterPostForm(r *http.Request) chatterPostForm { - return chatterPostForm{ - ModelName: strings.TrimSpace(r.PostFormValue(recordModelField)), - RecordIDRaw: strings.TrimSpace(r.PostFormValue(chatterRecordIDField)), - Body: strings.TrimSpace(r.PostFormValue(chatterBodyField)), - Next: SafeWebNext(r.PostFormValue(nextField), homeRoute), - } -} - -func validateChatterPost(r *http.Request, form chatterPostForm) (recordID int64, err error) { - if form.ModelName == "" { - return 0, fmt.Errorf("missing model") - } - if chatterBodyTooLong(form.Body) { - return 0, fmt.Errorf("message too long") - } - if _, ok := orm.Registry[form.ModelName]; !ok { - return 0, fmt.Errorf("unknown model") - } - if form.ModelName == mailMessageModel { - return 0, fmt.Errorf("invalid model") - } - - recordID, err = parseChatterRecordID(form.RecordIDRaw) - if err != nil { - return 0, err - } - if !chatterTargetRecordExists(r, form.ModelName, recordID) { - return 0, fmt.Errorf("record not found") - } - if err := orm.CheckModelAccess(r.Context(), orm.SecurityUID(r.Context()), form.ModelName, "write"); err != nil { - return 0, fmt.Errorf("access denied") - } - - return recordID, nil -} - -func parseChatterRecordID(rawRecordID string) (int64, error) { - recordID, err := strconv.ParseInt(rawRecordID, 10, 64) - if err != nil || recordID <= 0 { - return 0, fmt.Errorf("invalid res_id") - } - return recordID, nil -} - -func chatterTargetRecordExists(r *http.Request, modelName string, recordID int64) bool { - _, err := orm.SearchOne(r.Context(), modelName, map[string]interface{}{"id": int(recordID)}) - return err == nil -} - -func chatterBodyTooLong(body string) bool { - return utf8.RuneCountInString(body) > maxChatterBodyRunes -} - -func postChatterComment(ctx context.Context, modelName string, recordID int64, body string) error { - return mail.PostMessage(ctx, modelName, recordID, body, mail.SubtypeComment, chatterDefaultAuthor) -} 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/core/server/web/routes_table.go b/core/server/web/routes_table.go index 15cd7546..382231ea 100644 --- a/core/server/web/routes_table.go +++ b/core/server/web/routes_table.go @@ -68,7 +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, importCSVRoute, ImportCSVHandler) } @@ -89,6 +88,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_activity_log.go b/core/server/web/swc_activity_log.go new file mode 100644 index 00000000..6f963041 --- /dev/null +++ b/core/server/web/swc_activity_log.go @@ -0,0 +1,107 @@ +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"` + 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). +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 !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 + } + offset, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("offset"))) + if offset < 0 { + offset = 0 + } + 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{ + 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"])) + 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, + }) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(out) +} diff --git a/core/server/web/swc_chatter.go b/core/server/web/swc_chatter.go deleted file mode 100644 index 9cb46ab6..00000000 --- a/core/server/web/swc_chatter.go +++ /dev/null @@ -1,68 +0,0 @@ -package web - -import ( - "encoding/json" - "net/http" - "strconv" - "strings" - - "sumeru/addons/mail" -) - -const swcChatterRoute = "/web/swc/chatter" - -func registerSwcChatterRoute() { - registerSession(http.MethodGet, swcChatterRoute, SwcChatterHandler) -} - -type swcChatterMessage struct { - Body string `json:"body"` - Author string `json:"author"` - CreateDate string `json:"createDate"` - Subtype string `json:"subtype"` -} - -type swcChatterPayload struct { - Model string `json:"model"` - RecordID int64 `json:"recordId"` - Messages []swcChatterMessage `json:"messages"` - Enabled bool `json:"enabled"` -} - -// SwcChatterHandler GET /web/swc/chatter?model=&id= -func SwcChatterHandler(w http.ResponseWriter, r *http.Request) { - if !requireLogin(w, r) { - return - } - if !mail.CompanyChatterEnabled(r.Context()) { - writeJSONResponse(w, swcChatterPayload{Enabled: false}) - return - } - model := strings.TrimSpace(r.URL.Query().Get("model")) - idRaw := strings.TrimSpace(r.URL.Query().Get("id")) - id, _ := strconv.ParseInt(idRaw, 10, 64) - if model == "" || id <= 0 { - http.Error(w, "model and id required", http.StatusBadRequest) - return - } - rows, err := mail.ListCommentsForRecord(r.Context(), model, id, 120) - if err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - out := swcChatterPayload{ - Model: model, - RecordID: id, - Enabled: true, - } - for _, row := range rows { - out.Messages = append(out.Messages, swcChatterMessage{ - Body: row.Body, - Author: row.Author, - CreateDate: row.CreateDate.UTC().Format("2006-01-02 15:04:05"), - Subtype: row.Subtype, - }) - } - 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..2d3c3647 --- /dev/null +++ b/core/server/web/swc_direct.go @@ -0,0 +1,211 @@ +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" + 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) + registerSession(http.MethodPost, swcDirectPostRoute, SwcDirectPostHandler) + 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) { + 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")) + resModel := strings.TrimSpace(r.PostFormValue("res_model")) + resID, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("res_id"))) + uid := AuthenticatedUserID(r) + id, err := im.PostDirectMessageLinked(r.Context(), uid, recipientID, body, resModel, resID) + 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_import.go b/core/server/web/swc_import.go new file mode 100644 index 00000000..971d0dc0 --- /dev/null +++ b/core/server/web/swc_import.go @@ -0,0 +1,62 @@ +package web + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "sumeru/core/orm" + "sumeru/core/report" +) + +func registerSwcImportRoute() { + registerSession(http.MethodGet, swcImportRoute, SwcImportHandler) +} + +// SwcImportHandler GET /web/swc/import?batch= — import wizard bootstrap JSON. +func SwcImportHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + batchID, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("batch"))) + if batchID <= 0 { + http.Error(w, "batch required", http.StatusBadRequest) + return + } + batch, err := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": batchID}) + if err != nil || len(batch) == 0 { + http.NotFound(w, r) + return + } + targetModel := orm.AsString(batch["target_model"]) + if !requireModelAccess(w, r, targetModel, "create") { + return + } + var headers []string + _ = json.Unmarshal([]byte(orm.AsString(batch["csv_headers"])), &headers) + mapping := map[string]string{} + _ = json.Unmarshal([]byte(orm.AsString(batch["column_mapping"])), &mapping) + var fields []string + _ = json.Unmarshal([]byte(orm.AsString(batch["selected_fields"])), &fields) + modelInst, _ := orm.Registry[targetModel] + fieldLabels := map[string]string{} + if modelInst != nil { + for _, f := range modelInst.Fields() { + if f.Name != "" { + fieldLabels[f.Name] = f.String + } + } + } + writeJSONResponse(w, map[string]interface{}{ + "batchId": batchID, + "targetModel": targetModel, + "importMode": orm.AsString(batch["import_mode"]), + "headers": headers, + "mapping": mapping, + "fields": fields, + "fieldLabels": fieldLabels, + "state": orm.AsString(batch["state"]), + "nextUrl": orm.AsString(batch["next_url"]), + }) +} 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..0a9f3b82 100644 --- a/core/server/web/swc_payload.go +++ b/core/server/web/swc_payload.go @@ -74,9 +74,25 @@ 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 || model == "" { + return + } + uid := orm.SecurityUID(ctx) + if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil { + return + } + if !orm.RecordAuditLogAvailable(ctx) { + return + } + payload.RecordLogEligible = true +} + 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 5cda26e6..7b749310 100644 --- a/core/server/web/swc_workspace.go +++ b/core/server/web/swc_workspace.go @@ -11,8 +11,10 @@ func registerSwcRoutes() { registerSession(http.MethodGet, swcWorkspaceRoute, SwcWorkspaceHandler) registerSwcSavedSearchRoutes() registerSwcBusRoute() - registerSwcChatterRoute() + registerSwcActivityLogRoute() registerSwcNotificationRoutes() + registerSwcDirectRoutes() + registerSwcImportRoute() } // SwcWorkspaceHandler GET /web/swc/workspace — JSON workspace payload for SWC. diff --git a/core/server/web/testexports.go b/core/server/web/testexports.go index 1251c0c9..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{} { @@ -392,6 +383,73 @@ 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 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) +} + +func ExportAsyncHandlerForTest(w http.ResponseWriter, r *http.Request) { + ExportAsyncHandler(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..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" @@ -273,6 +272,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" @@ -282,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/esbuild.config.mjs b/core/swc/esbuild.config.mjs index 8badf2a1..a0d2eb5b 100644 --- a/core/swc/esbuild.config.mjs +++ b/core/swc/esbuild.config.mjs @@ -13,6 +13,7 @@ const appsPageOutFile = join(__dirname, "../engine/assets/js/apps-page.js"); mkdirSync(dirname(swcOutFile), { recursive: true }); mkdirSync(dirname(passwordMatchOutFile), { recursive: true }); mkdirSync(dirname(appsPageOutFile), { recursive: true }); +mkdirSync(join(__dirname, "../engine/assets/js"), { recursive: true }); const compileSumXml = await loadCompileSumXml(); @@ -88,6 +89,17 @@ await esbuild.build({ minify: process.env.NODE_ENV === "production", }); +const importOutFile = join(__dirname, "../engine/assets/js/sumeru-import.js"); +await esbuild.build({ + entryPoints: [join(__dirname, "src/import/import-entry.ts")], + bundle: true, + format: "iife", + outfile: importOutFile, + target: "es2022", + sourcemap: true, + minify: process.env.NODE_ENV === "production", +}); + console.log(`Login password toggle → ${passwordToggleOutFile}`); console.log(`Login password match → ${passwordMatchOutFile}`); console.log(`Apps page script → ${appsPageOutFile}`); 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/core/swc/src/import/ImportWizard.ts b/core/swc/src/import/ImportWizard.ts new file mode 100644 index 00000000..922b76b8 --- /dev/null +++ b/core/swc/src/import/ImportWizard.ts @@ -0,0 +1,154 @@ +import { SWC_API_BASE } from "../constants/routes.js"; + +interface ImportBootstrap { + batchId: number; + targetModel: string; + importMode: string; + headers: string[]; + mapping: Record; + fields: string[]; + fieldLabels: Record; + nextUrl?: string; +} + +export function mountImportWizard(host: HTMLElement | null, batchId: string, csrf: string): void { + if (!host || !batchId) return; + void load(host, batchId, csrf); +} + +async function load(host: HTMLElement, batchId: string, csrf: string): Promise { + const res = await fetch(`${SWC_API_BASE}/import?batch=${encodeURIComponent(batchId)}`, { credentials: "same-origin" }); + if (!res.ok) { + host.textContent = "Could not load import batch."; + return; + } + const data = (await res.json()) as ImportBootstrap; + render(host, data, csrf); +} + +function render(host: HTMLElement, data: ImportBootstrap, csrf: string): void { + host.innerHTML = ""; + const mapping = { ...data.mapping }; + const form = document.createElement("div"); + form.className = "sum-import-wizard"; + + const table = document.createElement("table"); + table.className = "sum-import-map-table"; + const thead = document.createElement("thead"); + thead.innerHTML = "File columnField"; + table.appendChild(thead); + const tbody = document.createElement("tbody"); + for (const col of data.headers) { + const tr = document.createElement("tr"); + const tdCol = document.createElement("td"); + tdCol.textContent = col; + const tdField = document.createElement("td"); + const select = document.createElement("select"); + select.dataset.column = col; + appendOption(select, "-", "Ignore"); + for (const f of data.fields) { + appendOption(select, f, data.fieldLabels[f] || f); + } + select.value = mapping[col] ?? "-"; + select.addEventListener("change", () => { + mapping[col] = select.value; + }); + tdField.appendChild(select); + tr.append(tdCol, tdField); + tbody.appendChild(tr); + } + table.appendChild(tbody); + form.appendChild(table); + + const previewBox = document.createElement("pre"); + previewBox.className = "sum-import-preview"; + form.appendChild(previewBox); + + const actions = document.createElement("div"); + actions.className = "sum-import-actions"; + + const previewBtn = button("Preview", () => void runPreview(previewBox, data.batchId, mapping, csrf)); + const dryBtn = button("Dry run", () => void runDryRun(previewBox, data.batchId, mapping, csrf)); + const saveTplBtn = button("Save template", () => void runSaveTemplate(previewBox, data.batchId, mapping, csrf)); + const importBtn = button("Import", () => void runImport(data.batchId, mapping, csrf, data.nextUrl ?? "/web/home")); + actions.append(previewBtn, dryBtn, saveTplBtn, importBtn); + form.appendChild(actions); + + host.appendChild(form); +} + +function appendOption(select: HTMLSelectElement, value: string, label: string): void { + const opt = document.createElement("option"); + opt.value = value; + opt.textContent = label; + select.appendChild(opt); +} + +function button(label: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement("button"); + b.type = "button"; + b.className = "sum-btn sum-btn--secondary"; + b.textContent = label; + b.addEventListener("click", onClick); + return b; +} + +async function runPreview(box: HTMLElement, batchId: number, mapping: Record, csrf: string): Promise { + box.textContent = "Previewing…"; + const res = await postJSON("/web/bulk/preview", { batch_id: batchId, column_mapping: mapping }, csrf); + box.textContent = JSON.stringify(res, null, 2); +} + +async function runSaveTemplate(box: HTMLElement, batchId: number, mapping: Record, csrf: string): Promise { + const name = window.prompt("Template name"); + if (!name?.trim()) { + return; + } + box.textContent = "Saving template…"; + const res = await postJSON("/web/bulk/save-template", { + name: name.trim(), + batch_id: batchId, + column_mapping: mapping, + shared: false, + }, csrf); + box.textContent = `Saved template #${String((res as { template_id?: number }).template_id ?? "")}`; +} + +async function runDryRun(box: HTMLElement, batchId: number, mapping: Record, csrf: string): Promise { + box.textContent = "Dry run…"; + const res = await postJSON("/web/bulk/dry-run", { batch_id: batchId, column_mapping: mapping }, csrf); + box.textContent = JSON.stringify(res, null, 2); +} + +async function runImport(batchId: number, mapping: Record, csrf: string, nextUrl: string): Promise { + const body = new URLSearchParams({ + id: String(batchId), + column_mapping: JSON.stringify(mapping), + csrf_token: csrf, + }); + const res = await fetch("/web/bulk/confirm", { method: "POST", credentials: "same-origin", body }); + if (res.redirected) { + window.location.href = res.url; + return; + } + if (res.ok) { + window.location.href = nextUrl || "/web/home"; + } +} + +async function postJSON(url: string, payload: Record, csrf: string): Promise { + const res = await fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-CSRF-Token": csrf, + }, + body: JSON.stringify({ ...payload, csrf_token: csrf }), + }); + if (!res.ok) { + throw new Error(await res.text()); + } + return res.json(); +} diff --git a/core/swc/src/import/import-entry.ts b/core/swc/src/import/import-entry.ts new file mode 100644 index 00000000..1555434a --- /dev/null +++ b/core/swc/src/import/import-entry.ts @@ -0,0 +1,8 @@ +import { mountImportWizard } from "./ImportWizard.js"; + +const root = document.getElementById("sum-import-root"); +if (root) { + const batch = root.dataset.batch ?? ""; + const csrf = root.dataset.csrf ?? ""; + mountImportWizard(document.getElementById("sum-import-app"), batch, csrf); +} 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/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/shell/activity-context.ts b/core/swc/src/shell/activity-context.ts new file mode 100644 index 00000000..d48a0429 --- /dev/null +++ b/core/swc/src/shell/activity-context.ts @@ -0,0 +1,28 @@ +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, + }; +} 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 new file mode 100644 index 00000000..5ce60114 --- /dev/null +++ b/core/swc/src/shell/activity-log.ts @@ -0,0 +1,127 @@ +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; + hasMore?: boolean; + offset?: number; +} + +export function initActivityLog(env: SwcEnv): void { + const host = document.getElementById("sum-activity-pane-log"); + 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 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) => `
+ +
${escapeHtml(item.meta)}
${escapeHtml(item.body)}
+
`, + ) + .join(""); + 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, 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}&offset=${offset}`, + ); + logOffset = (data.offset ?? offset) + (data.items?.length ?? 0); + logHasMore = data.hasMore === true; + const items = data.items ?? []; + if (data.hasLog === true || items.length > 0 || append) { + renderItems(items, append); + } else { + renderEmpty(); + } + } catch { + renderEmpty(); + } + }; + + env.services.bus.subscribe(ACTIVITY_CONTEXT, (payload) => { + current = payload as ActivityContextPayload; + logOffset = 0; + logHasMore = false; + cachedItems = []; + 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; + logOffset = 0; + logHasMore = false; + cachedItems = []; + 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..aca74663 100644 --- a/core/swc/src/shell/activity-panel.ts +++ b/core/swc/src/shell/activity-panel.ts @@ -30,27 +30,67 @@ 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); }); }); } +/** 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"]'); + 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; @@ -73,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 new file mode 100644 index 00000000..ff941b22 --- /dev/null +++ b/core/swc/src/shell/internal-messages-panel.ts @@ -0,0 +1,615 @@ +import { SwcComponent } from "../runtime/component.js"; +import { html } from "../template/html.js"; +import { SWC_API_BASE, VIEW_FORM } from "../constants/routes.js"; +import { SwcError } from "../runtime/error.js"; +import { inputValueFromEvent } from "../widgets/field-events.js"; +import { ACTIVITY_CONTEXT, type ActivityContextPayload } from "./activity-context.js"; +import { 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; + name: string; + login: string; +} + +interface DirectConversation { + userId: number; + name: string; + preview?: string; + lastMessage?: string; + unreadCount?: number; +} + +interface DirectAttachment { + id: number; + name: string; + url: string; +} + +interface DirectMessage { + id: number; + 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 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 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 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.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 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.serverSearchResults = data.users ?? []; + } catch { + this.serverSearchResults = []; + } + this.rerender(); + } + + private scheduleSearch(): void { + if (this.searchTimer) clearTimeout(this.searchTimer); + 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.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; + this.rerender(); + try { + const data = await this.env.services.http.getJSON<{ messages: DirectMessage[] }>( + `${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(); + } + } + + private async postMessage(): Promise { + const body = this.draft.trim(); + 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`, 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(); + } + } + + 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`, + 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`, this.postFields(" ")); + 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(`${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.imDisabled) { + return html`
    +

    Messages

    +

    ${this.disabledReason}

    + ${this.companyFormHref + ? html`

    + Open company settings + and enable Internal chat. +

    ` + : ""} +
    `; + } + + 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)} + /> +
    ${sidebarBlocks}
    +
    +
    + ${this.peerId <= 0 + ? html`
    +

    Your messages

    +

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

    +
    + ${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 c8ed5c9e..07292abe 100644 --- a/core/swc/src/shell/shell-chrome.ts +++ b/core/swc/src/shell/shell-chrome.ts @@ -1,7 +1,13 @@ 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 { 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"; import { initSidebar } from "./sidebar.js"; @@ -11,7 +17,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; @@ -19,8 +25,16 @@ 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()); + } } initPinnedApps(http, boot.pinnedApps ?? []); 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/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; } 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/src/views/chatter/ChatterPanel.ts b/core/swc/src/views/chatter/ChatterPanel.ts index bafe6d5d..b39084ce 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; @@ -28,6 +29,8 @@ interface ChatterPanelProps { model: string; recordId: number; csrfToken: string; + /** When true, omit outer aside chrome (activity dock mount). */ + embedded?: boolean; } export class ChatterPanel extends SwcComponent { @@ -37,7 +40,9 @@ export class ChatterPanel extends SwcComponent { private loading = true; 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 { @@ -59,6 +64,12 @@ export class ChatterPanel extends SwcComponent { this.unsubRecordUpdated = null; } + override onPropsChanged(props: ChatterPanelProps): void { + if (props.model !== this.props.model || props.recordId !== this.props.recordId) { + void this.load(); + } + } + private async load(): Promise { const { model, recordId } = this.props; if (recordId <= 0) { @@ -82,6 +93,31 @@ 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(); + } 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; @@ -109,31 +145,50 @@ export class ChatterPanel extends SwcComponent { override template() { if (this.props.recordId <= 0) { - return html``; + return html`
    Save the record to post messages.
    `; } - if (!this.enabled) return html``; if (this.loading) { - return html``; + return html`
    Loading messages…
    `; } - return html` - + ${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/shared/AttachmentPreview.ts b/core/swc/src/views/shared/AttachmentPreview.ts new file mode 100644 index 00000000..93376f39 --- /dev/null +++ b/core/swc/src/views/shared/AttachmentPreview.ts @@ -0,0 +1,34 @@ +import { html } from "../../template/html.js"; + +export interface AttachmentPreviewTarget { + name: string; + url: string; + mimetype?: string; +} + +export function attachmentPreviewModal( + target: AttachmentPreviewTarget | null, + onClose: () => void, +): ReturnType { + if (!target) return html``; + const mime = (target.mimetype ?? "").toLowerCase(); + const isPDF = mime === "application/pdf" || target.name.toLowerCase().endsWith(".pdf"); + const isImage = mime.startsWith("image/"); + return html` +
    +
    e.stopPropagation()}> +
    + ${target.name} + +
    +
    + ${isImage + ? html`${target.name}` + : isPDF + ? html`` + : html`Download ${target.name}`} +
    +
    +
    + `; +} 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/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/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", () => { 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/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/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= 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/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/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/core/coverage/exports_test.go b/test/core/coverage/exports_test.go index 7b72ee20..42227e20 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" @@ -182,6 +184,22 @@ func TestForTestExports_report(t *testing.T) { if sheet == "" { t.Fatal("SheetXMLForTest empty") } + csvBytes, err := report.ParseUploadContentForTest("t.csv", []byte("h\n1\n")) + if err != nil || len(csvBytes) == 0 { + t.Fatalf("ParseUploadContentForTest: err=%v", err) + } + if err := report.RunBulkJobCronForTest(context.Background()); err != nil { + t.Fatalf("RunBulkJobCronForTest: %v", err) + } +} + +func TestForTestExports_reportCoerce(t *testing.T) { + t.Parallel() + _, err := orm.ResolveContentAttachmentForTest(context.Background(), 1, "", "", 0) + if err == nil { + t.Fatal("ResolveContentAttachmentForTest expected error") + } + _ = report.ApplyImportTemplateMapping(context.Background(), "core.partner", []string{"Email"}, 1) } func TestForTestExports_swcmeta(t *testing.T) { @@ -373,4 +391,42 @@ func TestForTestExports_web(t *testing.T) { if !ok || flash.Title != "err" { t.Fatalf("record error flash: ok=%v flash=%+v", ok, flash) } + if _, id, ok := web.ParseContentPathForTest("12"); !ok || id != 12 { + t.Fatalf("ParseContentPathForTest: id=%d ok=%v", id, ok) + } + if !web.ContentInlineAllowedForTest("image/png") { + t.Fatal("ContentInlineAllowedForTest image") + } + payload := &swcmeta.WorkspacePayload{} + web.ApplyActivityRecordFlagsForTest(context.Background(), payload, "list", 0, "core.user") + if payload.RecordMessagesEligible { + t.Fatal("record messages flag should stay off") + } + web.ApplyActivityRecordFlagsForTest(context.Background(), payload, "form", 0, "core.user") + 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) + } + 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/im/im_test.go b/test/core/im/im_test.go new file mode 100644 index 00000000..eb80aca0 --- /dev/null +++ b/test/core/im/im_test.go @@ -0,0 +1,75 @@ +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 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) + 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/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/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/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/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/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) + } +} 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/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") + } +} diff --git a/test/core/report/import_coerce_test.go b/test/core/report/import_coerce_test.go new file mode 100644 index 00000000..5643d8d9 --- /dev/null +++ b/test/core/report/import_coerce_test.go @@ -0,0 +1,177 @@ +package report_test + +import ( + "context" + "testing" + + "sumeru/core/orm" + "sumeru/core/report" +) + +type stubImportModel struct{} + +func (stubImportModel) ModelName() string { return "stub.import" } +func (stubImportModel) Fields() []orm.FieldDefinition { + return []orm.FieldDefinition{ + {Name: "active", Type: orm.Boolean}, + {Name: "amount", Type: orm.Float}, + {Name: "qty", Type: orm.Integer}, + } +} + +func TestCoerceImportBooleanAndFloat(t *testing.T) { + out, errs := report.CoerceImportValuesForTest(context.Background(), stubImportModel{}, map[string]interface{}{ + "active": "true", + "amount": "3.5", + }) + if len(errs) > 0 { + t.Fatalf("errs=%v", errs) + } + if out["active"] != true { + t.Fatalf("active=%v", out["active"]) + } + if out["amount"] != 3.5 { + t.Fatalf("amount=%v", out["amount"]) + } +} + +func TestParseUploadContentInvalidXLSX(t *testing.T) { + _, err := report.ParseUploadContentForTest("bad.xlsx", []byte("not-a-zip")) + if err == nil { + t.Fatal("expected xlsx error") + } +} + +func TestParseUploadContentCSVPassThrough(t *testing.T) { + in := []byte("a,b\n1,2\n") + out, err := report.ParseUploadContentForTest("data.csv", in) + if err != nil { + t.Fatal(err) + } + if string(out) != string(in) { + t.Fatalf("got %q", out) + } +} + +type selImportModel struct{} + +func (selImportModel) ModelName() string { return "sel" } +func (selImportModel) Fields() []orm.FieldDefinition { + return []orm.FieldDefinition{{Name: "state", Type: orm.Selection, Selection: [][]string{{"a", "A"}}}} +} + +type dateImportModel struct{} + +func (dateImportModel) ModelName() string { return "d" } +func (dateImportModel) Fields() []orm.FieldDefinition { + return []orm.FieldDefinition{{Name: "day", Type: orm.Date}} +} + +func TestCoerceImportDateParsing(t *testing.T) { + out, errs := report.CoerceImportValuesForTest(context.Background(), dateImportModel{}, map[string]interface{}{"day": "2024-01-02"}) + if len(errs) > 0 { + t.Fatalf("errs=%v", errs) + } + if out["day"] == nil { + t.Fatal("expected date") + } +} + +func TestCoerceImportSelectionInvalid(t *testing.T) { + _, errs := report.CoerceImportValuesForTest(context.Background(), selImportModel{}, map[string]interface{}{"state": "bad"}) + if len(errs) == 0 { + t.Fatal("expected selection error") + } +} + +func TestDryRunBulkImportMissingBatch(t *testing.T) { + _, _, err := report.DryRunBulkImport(context.Background(), report.PreviewBulkImportInput{BatchID: 1}) + if err == nil { + t.Fatal("expected error without database batch") + } +} + +func TestRunBulkJobCronNoDB(t *testing.T) { + if err := report.RunBulkJobCronForTest(context.Background()); err != nil { + t.Fatalf("cron without db: %v", err) + } +} + +func TestQueueExportJobMissingModel(t *testing.T) { + _, err := report.QueueExportJob(context.Background(), "no.such.model", []string{"name"}, nil, "csv", 1) + if err == nil { + t.Fatal("expected error") + } +} + +func TestApplyImportTemplateMappingNoDB(t *testing.T) { + if m := report.ApplyImportTemplateMapping(context.Background(), "core.partner", []string{"Name"}, 1); m != nil { + t.Fatalf("expected nil without db, got %v", m) + } +} + +func TestSaveImportTemplateNoRegistry(t *testing.T) { + _, err := report.SaveImportTemplate(context.Background(), "T", "core.partner", "create", "[]", "{}", 1, false) + if err == nil { + t.Fatal("expected error without template model") + } +} + +func TestCountBatchRowsMissing(t *testing.T) { + _, err := report.CountBatchRows(context.Background(), 1) + if err == nil { + t.Fatal("expected error") + } +} + +func TestFetchRowsUnlimitedMissingModel(t *testing.T) { + _, err := report.FetchRowsUnlimitedForTest(context.Background(), "missing.model", nil, 0) + if err == nil { + t.Fatal("expected error") + } +} + +type dtImportModel struct{} + +func (dtImportModel) ModelName() string { return "dt" } +func (dtImportModel) Fields() []orm.FieldDefinition { + return []orm.FieldDefinition{{Name: "at", Type: orm.DateTime}} +} + +func TestCoerceImportDateTimeRFC3339(t *testing.T) { + out, errs := report.CoerceImportValuesForTest(context.Background(), dtImportModel{}, map[string]interface{}{"at": "2024-01-02T15:04:05Z"}) + if len(errs) > 0 { + t.Fatalf("errs=%v", errs) + } + if out["at"] == nil { + t.Fatal("expected time") + } +} + +func TestCoerceImportIntegerInvalid(t *testing.T) { + _, errs := report.CoerceImportValuesForTest(context.Background(), stubImportModel{}, map[string]interface{}{"qty": "not-int"}) + if len(errs) == 0 { + t.Fatal("expected int error") + } +} + +func TestExportCSVUnlimitedMissingModel(t *testing.T) { + _, err := report.ExportCSVUnlimitedForTest(context.Background(), report.ExportCSVInput{Model: "nope", Fields: []string{"x"}}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRunExportJobMissingModel(t *testing.T) { + err := report.RunExportJobForTest(context.Background(), map[string]interface{}{"target_model": ""}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestPreviewBulkImportZeroBatch(t *testing.T) { + _, err := report.PreviewBulkImport(context.Background(), report.PreviewBulkImportInput{}) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/test/core/server/web/bulk_preview_handler_test.go b/test/core/server/web/bulk_preview_handler_test.go new file mode 100644 index 00000000..cb2ed90f --- /dev/null +++ b/test/core/server/web/bulk_preview_handler_test.go @@ -0,0 +1,98 @@ +package web_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "sumeru/core/server/web" +) + +func TestSwcImportHandlerMissingBatchRecord(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodGet, "/web/swc/import?batch=1", nil) + rec := httptest.NewRecorder() + web.SwcImportHandlerForTest(rec, req) + if rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestSwcImportHandlerRequiresBatch(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodGet, "/web/swc/import", nil) + rec := httptest.NewRecorder() + web.SwcImportHandlerForTest(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestImportWizardPageRequiresBatch(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodGet, "/web/import", nil) + rec := httptest.NewRecorder() + web.ImportWizardPageHandlerForTest(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d", rec.Code) + } +} + +func TestExportAsyncHandlerRequiresModel(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodPost, "/web/export/async", bytes.NewReader([]byte("csrf_token=x"))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + web.ExportAsyncHandlerForTest(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("expected failure without model") + } +} + +func TestBulkDryRunHandlerRejectsBadBody(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodPost, "/web/bulk/dry-run", bytes.NewReader([]byte(`{"batch_id":0}`))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + web.BulkDryRunHandlerForTest(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("expected failure") + } +} + +func TestBulkSaveImportTemplateHandlerRejectsBadBody(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodPost, "/web/bulk/save-template", bytes.NewReader([]byte(`{"batch_id":1}`))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + web.BulkSaveImportTemplateHandlerForTest(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("expected failure without name") + } +} + +func TestBulkPreviewHandlerRejectsBadBody(t *testing.T) { + web.ResetTestSessionUserIDForTest() + t.Cleanup(web.ResetTestSessionUserIDForTest) + web.SetTestSessionUserIDForTest(1) + req := httptest.NewRequest(http.MethodPost, "/web/bulk/preview", bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + web.BulkPreviewHandlerForTest(rec, req) + if rec.Code != http.StatusBadRequest && rec.Code != http.StatusForbidden { + t.Fatalf("status=%d", rec.Code) + } +} 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") + } +} diff --git a/test/core/server/web/csrf_test.go b/test/core/server/web/csrf_test.go index 3a3cd8fe..fa7cc494 100644 --- a/test/core/server/web/csrf_test.go +++ b/test/core/server/web/csrf_test.go @@ -34,6 +34,26 @@ func TestValidateCSRF_acceptsMatchingHeader(t *testing.T) { } } +func TestValidateCSRF_acceptsMatchingQueryOnGET(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/web/export/csv?model=core.user&fields=name", nil) + req.AddCookie(&http.Cookie{Name: web.TestSessionCookieName, Value: "export-session-sid"}) + token := web.CSRFTokenForRequest(req) + req.URL.RawQuery = "model=core.user&fields=name&csrf_token=" + token + if !web.ValidateCSRF(req) { + t.Fatal("expected CSRF validation to pass with matching query token on GET") + } +} + +func TestValidateCSRF_rejectsQueryTokenOnPOST(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/web/bulk/confirm?csrf_token=only-in-query", nil) + req.AddCookie(&http.Cookie{Name: web.TestSessionCookieName, Value: "post-session-sid"}) + token := web.CSRFTokenForRequest(req) + req.URL.RawQuery = "csrf_token=" + token + if web.ValidateCSRF(req) { + t.Fatal("expected CSRF validation to fail when token is only in query on POST") + } +} + func TestValidateProductionCSRFSecret_devModeSkips(t *testing.T) { prevDev := config.AppConfig.DevMode prevSecret := config.AppConfig.CSRFSecret diff --git a/test/core/server/web/export_handlers_test.go b/test/core/server/web/export_handlers_test.go index 1fbe7619..2ff27682 100644 --- a/test/core/server/web/export_handlers_test.go +++ b/test/core/server/web/export_handlers_test.go @@ -29,6 +29,21 @@ func TestResolveExportRequestMissingModel(t *testing.T) { } } +func TestResolveExportRequestMissingModelWithCSRFQuery(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/web/export/csv?fields=name", nil) + req.AddCookie(&http.Cookie{Name: web.TestSessionCookieName, Value: "export-resolve-sid"}) + token := web.CSRFTokenForRequestForTest(req) + req.URL.RawQuery = "fields=name&csrf_token=" + token + _, ok := web.ResolveExportRequestForTest(rr, req) + if ok { + t.Fatal("expected resolve export request to fail without model") + } + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d after CSRF passes", rr.Code, http.StatusBadRequest) + } +} + func TestExportTemplatePDFHandlerRequiresLogin(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/web/report/template-pdf?title=Demo", nil) 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/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") - } -} 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") + } +} 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) } }