Files
sm-journal-plugin/controllers/api/record_events.go
Jakub Zych 4b3a8bb049 feat: record events from the Journal API writes (D-102)
- POST/PUT/DELETE /_journal/api/v1/posts fire RecordSaved/RecordDeleted
  through cabana.EmitRecordSaved/EmitRecordDeleted, so a slug changed over
  MCP reaches subscribers like an admin save
- PUT reports the changed columns and each submitted translation that
  changed, read before the write (a translated slug per locale)
- TestAPIRecordEvents covers POST, PUT slug change and DELETE
2026-10-26 22:35:08 +01:00

162 lines
5.3 KiB
Go

package api
import (
"context"
"reflect"
"sort"
"strconv"
"time"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
// postEventColumns are the post columns a record event reports, in order.
var postEventColumns = []struct {
name string
value func(p *models.Post) any
}{
{"user_id", func(p *models.Post) any { return derefValue(p.UserID) }},
{"redactor_id", func(p *models.Post) any { return derefValue(p.RedactorID) }},
{"title", func(p *models.Post) any { return p.Title }},
{"slug", func(p *models.Post) any { return p.Slug }},
{"excerpt", func(p *models.Post) any { return p.Excerpt }},
{"content", func(p *models.Post) any { return p.Content }},
{"content_html", func(p *models.Post) any { return p.ContentHTML }},
{"published_at", func(p *models.Post) any { return derefValue(p.PublishedAt) }},
{"published", func(p *models.Post) any { return p.Published }},
{"is_pinned", func(p *models.Post) any { return p.IsPinned }},
{"metadata", func(p *models.Post) any { return derefValue(p.Metadata) }},
{"sources", func(p *models.Post) any { return derefValue(p.Sources) }},
}
// translatedEventFields are the translated fields a write may submit under
// "translations" (syncTranslations).
var translatedEventFields = []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
// postSnapshot is a post's values before an API write: its columns and the
// stored translations of the fields the request submits per locale.
type postSnapshot struct {
columns map[string]any
translations map[string]map[string]any // locale -> field -> old (nil when missing)
}
// snapshotPost takes the values an update may change, before it changes
// them. A translation read error leaves that value out (reported as new).
func snapshotPost(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) *postSnapshot {
snap := &postSnapshot{columns: map[string]any{}, translations: map[string]map[string]any{}}
for _, col := range postEventColumns {
snap.columns[col.name] = col.value(post)
}
for locale, fields := range submittedTranslations(body) {
snap.translations[locale] = map[string]any{}
for _, field := range fields {
text, ok, err := translate.TranslatedExact(ctx, db, post, field, locale)
if err == nil && ok {
snap.translations[locale][field] = text
} else {
snap.translations[locale][field] = nil
}
}
}
return snap
}
// postSavedEvent is the RecordSaved of an API write, which the handler
// fires with cabana.EmitRecordSaved once the write is committed (the API
// writes run outside a transaction, so at once). before is nil on create:
// every column with a value and every submitted translation is then
// reported with a nil Old. Controller is "": the write did not go through an
// admin form.
func postSavedEvent(ctx context.Context, db *gorm.DB, before *postSnapshot, post *models.Post, body map[string]any) pact.RecordSaved {
changes := []pact.RecordChange{}
for _, col := range postEventColumns {
next := col.value(post)
if before == nil {
if next == nil || reflect.ValueOf(next).IsZero() {
continue
}
changes = append(changes, pact.RecordChange{Attribute: col.name, New: next})
continue
}
prev := before.columns[col.name]
if sameEventValue(prev, next) {
continue
}
changes = append(changes, pact.RecordChange{Attribute: col.name, Old: prev, New: next})
}
submitted := submittedTranslations(body)
locales := make([]string, 0, len(submitted))
for locale := range submitted {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
for _, field := range submitted[locale] {
text, ok, err := translate.TranslatedExact(ctx, db, post, field, locale)
if err != nil || !ok {
continue
}
var prev any
if before != nil {
prev = before.translations[locale][field]
}
if old, _ := prev.(string); old == text {
continue
}
changes = append(changes, pact.RecordChange{Attribute: field, Locale: locale, Old: prev, New: text})
}
}
locale, _ := towel.Locale(ctx)
return pact.RecordSaved{
Model: post.MorphName(),
ID: strconv.FormatUint(uint64(post.ID), 10),
Locale: locale,
Created: before == nil,
Changes: changes,
}
}
// submittedTranslations lists, per locale, the translated fields a request
// body submits with a value, in translatedEventFields order.
func submittedTranslations(body map[string]any) map[string][]string {
out := map[string][]string{}
m, ok := body["translations"].(map[string]any)
if !ok {
return out
}
for locale, raw := range m {
attrs, ok := raw.(map[string]any)
if !ok {
continue
}
for _, field := range translatedEventFields {
if v, has := attrs[field]; has && v != nil {
out[locale] = append(out[locale], field)
}
}
}
return out
}
// derefValue is a pointer column's value, nil for a nil pointer.
func derefValue[T any](p *T) any {
if p == nil {
return nil
}
return *p
}
// sameEventValue compares two column values; times compare as instants.
func sameEventValue(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)
}