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
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
@@ -232,6 +233,7 @@ func Store(app *backpack.App) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
purgeSiteCache(r, app, post)
|
||||
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), nil, post, body))
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
||||
}
|
||||
}
|
||||
@@ -265,6 +267,9 @@ func Update(app *backpack.App) http.HandlerFunc {
|
||||
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||
return
|
||||
}
|
||||
// D-92/D-102: the values before the write, for the RecordSaved
|
||||
// subscribers hear (a slug changed over MCP gets its redirect).
|
||||
before := snapshotPost(r.Context(), db.WithContext(r.Context()), post, body)
|
||||
if verrs := applyPostUpdate(r, db.WithContext(r.Context()), post, body); verrs != nil {
|
||||
writeValidation(w, verrs)
|
||||
return
|
||||
@@ -286,6 +291,7 @@ func Update(app *backpack.App) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
purgeSiteCache(r, app, post)
|
||||
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), before, post, body))
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
||||
}
|
||||
}
|
||||
@@ -319,6 +325,10 @@ func Destroy(app *backpack.App) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
purgeSiteCache(r, app, post)
|
||||
cabana.EmitRecordDeleted(r.Context(), app, nil, pact.RecordDeleted{
|
||||
Model: post.MorphName(),
|
||||
ID: strconv.FormatUint(uint64(post.ID), 10),
|
||||
})
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgPostDeleted})
|
||||
}
|
||||
}
|
||||
|
||||
161
controllers/api/record_events.go
Normal file
161
controllers/api/record_events.go
Normal file
@@ -0,0 +1,161 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user