package cabana import ( "context" "reflect" "strconv" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/towel" "gorm.io/gorm" "gorm.io/gorm/schema" ) // EmitRecordSaved fires ev as a *pact.RecordSaved through the application's // event bus once tx commits, for plugin code that writes records outside the // admin forms (its own API handlers, console imports, record actions). tx is // the write's lagoon.Transaction handle: the event is buffered until that // transaction commits and dropped when it rolls back. With a nil tx, or // outside a transaction, it fires at once, so call it after the write has // committed. A missing application or bus is a no-op and a listener's error // is logged: the write that caused the event never fails because of it. func EmitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordSaved) { if app == nil || app.Events == nil { return } if ctx == nil { ctx = context.Background() } ev.Changes = append([]pact.RecordChange(nil), ev.Changes...) lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { event := ev if err := app.Events.Fire(ctx, &event); err != nil { moduleLog.WarnContext(ctx, "cabana: record saved listener failed", "controller", ev.Controller, "model", ev.Model, "id", ev.ID, "error", err) } }) } // EmitRecordDeleted fires ev as a *pact.RecordDeleted after tx commits, with // the rules of EmitRecordSaved. func EmitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordDeleted) { if app == nil || app.Events == nil { return } if ctx == nil { ctx = context.Background() } lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { event := ev if err := app.Events.Fire(ctx, &event); err != nil { moduleLog.WarnContext(ctx, "cabana: record deleted listener failed", "controller", ev.Controller, "model", ev.Model, "id", ev.ID, "error", err) } }) } // emitRecordSaved fires the admin save of model through controller cc after // tx commits. func emitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any, created bool, changes []pact.RecordChange) { if app == nil || app.Events == nil { return } EmitRecordSaved(ctx, app, tx, pact.RecordSaved{ Controller: controllerID(cc), Model: recordMorphType(tx, model), ID: recordIDText(model), Locale: eventLocale(ctx), Created: created, Changes: changes, }) } // eventLocale is the request locale an admin record event reports: the // primary tag of the context locale (a raw Accept-Language header becomes // its first tag), "" when the request has none. func eventLocale(ctx context.Context) string { locale, _ := towel.Locale(ctx) return primaryLanguage(locale) } // emitRecordDeleted fires the admin delete of model through controller cc // after tx commits. func emitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any) { if app == nil || app.Events == nil { return } EmitRecordDeleted(ctx, app, tx, pact.RecordDeleted{ Controller: controllerID(cc), Model: recordMorphType(tx, model), ID: recordIDText(model), }) } // controllerForRecord is the ID of the one admin controller whose records // have model's type, "" when none or several do. A nil registry knows none. func (r *Registry) controllerForRecord(model any) string { if r == nil || model == nil { return "" } want := reflect.TypeOf(model) found := "" for id, cc := range r.byID { src, ok := cc.Controller.(pact.AdminRecordSource) if !ok || src == nil { continue } record := src.NewRecord() if record == nil || reflect.TypeOf(record) != want { continue } if found != "" { return "" } found = id } return found } // reorderEvents are the RecordSaved events of a reorder save: one per node // whose parent or sort value changed, with the change in the model's column // types (a nil parent at the top). func reorderEvents(cc *CompiledController, morph string, locked []AdminReorderNode, nodes []AdminReorderNodeInput) []pact.RecordSaved { ro := cc.reorder proto := cc.Controller.(pact.AdminRecordSource).NewRecord() before := make(map[uint]AdminReorderNode, len(locked)) for _, n := range locked { before[n.ID] = n } var out []pact.RecordSaved for _, node := range nodes { old, ok := before[uint(node.ID)] if !ok { continue } var changes []pact.RecordChange if !ro.flat { var prev, next any if old.ParentID != nil { prev = columnValue(proto, ro.parentColumn, *old.ParentID) } if node.ParentID != nil { next = columnValue(proto, ro.parentColumn, uint(*node.ParentID)) } if !sameChangeValue(prev, next) { changes = append(changes, pact.RecordChange{Attribute: ro.parentColumn, Old: prev, New: next}) } } if old.SortOrder != node.SortOrder { changes = append(changes, pact.RecordChange{ Attribute: ro.sortColumn, Old: columnValue(proto, ro.sortColumn, old.SortOrder), New: columnValue(proto, ro.sortColumn, node.SortOrder), }) } if len(changes) == 0 { continue } out = append(out, pact.RecordSaved{ Controller: controllerID(cc), Model: morph, ID: strconv.FormatUint(node.ID, 10), Changes: changes, }) } return out } // columnValue converts v to the type model's column holds (a pointer // column's element type), so reorder changes match save changes. A column // the model does not have, or a type v does not convert to, keeps v. func columnValue(model any, column string, v any) any { rv := reflect.ValueOf(model) for rv.Kind() == reflect.Pointer { if rv.IsNil() { return v } rv = rv.Elem() } field := fieldByColumn(rv, column) if !field.IsValid() { return v } typ := field.Type() for typ.Kind() == reflect.Pointer { typ = typ.Elem() } val := reflect.ValueOf(v) if !val.IsValid() || !val.Type().ConvertibleTo(typ) { return v } return val.Convert(typ).Interface() } // recordMorphType is lagoon.MorphType, logged and "" when it fails. func recordMorphType(db *gorm.DB, model any) string { morph, err := lagoon.MorphType(db, model) if err != nil { moduleLog.Warn("cabana: record event morph type", "error", err) return "" } return morph } // recordSnapshot holds a record's column values and, for the translatable // fields a save submits, their stored non-default locale values, taken // before the save changes them, so the save can report what it changed. type recordSnapshot struct { columns []string values map[string]any translations map[string]map[string]any // field -> locale -> old value (nil when missing) } // snapshotColumns lists the columns a change is reported for: every column // except the primary key and the timestamps GORM maintains itself. func snapshotColumns(db *gorm.DB, model any) ([]*schema.Field, error) { stmt := &gorm.Statement{DB: db} if err := stmt.Parse(model); err != nil { return nil, err } out := []*schema.Field{} for _, name := range stmt.Schema.DBNames { field := stmt.Schema.FieldsByDBName[name] if field == nil || field.PrimaryKey || field.AutoCreateTime != 0 || field.AutoUpdateTime != 0 || !field.Readable { continue } out = append(out, field) } return out, nil } // takeRecordSnapshot reads model's column values and, through writer, the // stored values of the submitted translatable fields in their non-default // locales. A model that GORM cannot parse snapshots nothing. func takeRecordSnapshot(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, translations []mlValue) (*recordSnapshot, error) { snap := &recordSnapshot{values: map[string]any{}, translations: map[string]map[string]any{}} fields, err := snapshotColumns(tx, model) if err != nil { moduleLog.Warn("cabana: record event snapshot", "error", err) return snap, nil } rv := reflect.ValueOf(model) for _, field := range fields { value, _ := field.ValueOf(ctx, rv) snap.columns = append(snap.columns, field.DBName) snap.values[field.DBName] = changeValue(value) } if writer == nil || len(translations) == 0 { return snap, nil } def, err := writer.DefaultLocale(ctx, tx) if err != nil { return nil, err } for _, item := range translations { for locale := range item.values { if locale == def { continue } text, ok, err := writer.TranslatedExact(ctx, tx, model, item.field, locale) if err != nil { return nil, err } if snap.translations[item.field] == nil { snap.translations[item.field] = map[string]any{} } if ok { snap.translations[item.field][locale] = text } else { snap.translations[item.field][locale] = nil } } } return snap, nil } // recordChanges compares model after the save with the snapshot taken // before it: one change per column whose value differs, then one per // submitted translatable field and non-default locale whose text differs. // On create (snap nil) it lists every column the save wrote a value to and // every submitted non-empty translation, each with a nil Old. func recordChanges(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, snap *recordSnapshot, translations []mlValue) ([]pact.RecordChange, error) { changes := []pact.RecordChange{} fields, err := snapshotColumns(tx, model) if err != nil { moduleLog.Warn("cabana: record event changes", "error", err) return changes, nil } rv := reflect.ValueOf(model) for _, field := range fields { value, zero := field.ValueOf(ctx, rv) next := changeValue(value) if snap == nil { if zero || next == nil { continue } changes = append(changes, pact.RecordChange{Attribute: field.DBName, New: next}) continue } prev, known := snap.values[field.DBName] if known && sameChangeValue(prev, next) { continue } changes = append(changes, pact.RecordChange{Attribute: field.DBName, Old: prev, New: next}) } if writer == nil || len(translations) == 0 { return changes, nil } def, err := writer.DefaultLocale(ctx, tx) if err != nil { return nil, err } for _, item := range translations { for _, locale := range sortedLocales(item.values) { if locale == def { continue } next := item.values[locale] var prev any if snap != nil { prev = snap.translations[item.field][locale] } old, _ := prev.(string) if old == next { continue } changes = append(changes, pact.RecordChange{Attribute: item.field, Locale: locale, Old: prev, New: next}) } } return changes, nil } // changeValue is a column value as a change reports it: pointers are // dereferenced (a nil pointer is nil) and slices and maps are copied, so a // later write to the model does not reach the reported value. func changeValue(value any) any { rv := reflect.ValueOf(value) for rv.IsValid() && rv.Kind() == reflect.Pointer { if rv.IsNil() { return nil } rv = rv.Elem() } if !rv.IsValid() { return nil } switch rv.Kind() { case reflect.Slice: if rv.IsNil() { return nil } out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len()) reflect.Copy(out, rv) return out.Interface() case reflect.Map: if rv.IsNil() { return nil } out := reflect.MakeMapWithSize(rv.Type(), rv.Len()) iter := rv.MapRange() for iter.Next() { out.SetMapIndex(iter.Key(), iter.Value()) } return out.Interface() } return rv.Interface() } // sameChangeValue compares two change values; times compare as instants. func sameChangeValue(a, b any) bool { ta, okA := a.(time.Time) tb, okB := b.(time.Time) if okA && okB { return ta.Equal(tb) } return reflect.DeepEqual(a, b) }