Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a75bfcd
feat(platform): filestore-backed attachments and content handler ACL
ATRIwOX Oct 3, 2026
1442574
feat(web): chatter file upload and attachment preview in SWC
ATRIwOX Oct 3, 2026
8bddc02
feat(platform): bulk import/export engine, wizard, and async jobs
ATRIwOX Oct 3, 2026
77ce6d4
feat(platform): seed bulk-job cron and import template admin UI
ATRIwOX Oct 3, 2026
9f2539a
fix(web): accept CSRF token on query string for GET exports
ATRIwOX Oct 3, 2026
2889e78
Split kernel into geo, i18n, platform, audit, and im addons.
ATRIwOX Oct 3, 2026
bb7ec8a
Remove mail addon and record chatter endpoints.
ATRIwOX Oct 3, 2026
15fb77e
Wire activity dock to audit log and im direct messages.
ATRIwOX Oct 3, 2026
5c340e2
Add gettext PO catalogs for sys.translation.
ATRIwOX Oct 3, 2026
7d80973
Consolidate translation helpers and update tests for geo/i18n menus.
ATRIwOX Oct 3, 2026
8cbe08e
addons/base slimmed
ATRIwOX Oct 3, 2026
f605b20
workspace.ts updated recordmessages and record log initiated
ATRIwOX Oct 3, 2026
5b9ec00
Use ProjectMeru go-qr for TOTP PNGs and bump SWC test deps.
ATRIwOX Oct 3, 2026
ec24b7c
Add IM direct messaging APIs and record-scoped activity audit log.
ATRIwOX Oct 3, 2026
22d0cb8
Polish Messages tab with chat layout, fuzzy search, and activity Log UI.
ATRIwOX Oct 3, 2026
4d9af9e
Merge pull request #118 from ProjectMeru/platform
ATRIwOX Oct 3, 2026
71f10aa
Close SUM-PLAT-16 and SUM-PLAT-17: attachments and bulk I/O UX.
ATRIwOX Oct 3, 2026
e1dc1d0
Merge pull request #119 from ProjectMeru/platform
ATRIwOX Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 24 additions & 25 deletions addons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,42 @@ 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

```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 `<action type="window">`
- 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.
98 changes: 98 additions & 0 deletions addons/audit/audit_cleanup.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
166 changes: 166 additions & 0 deletions addons/audit/audit_export.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading