- 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
162 lines
5.3 KiB
Go
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)
|
|
}
|