Files
sm-journal-plugin/controllers/api/record_events.go
Jakub Zych 62c75b1fc2 fix: write API posts in one transaction and fire RecordSaved after its commit
POST and PUT /_journal/api/v1/posts saved the row, then the categories, tags
and translations, and fired RecordSaved only when all succeeded. A failure
part-way left a committed row (possibly a new slug) with no event, so
subscribers such as an SEO plugin never created the redirect. The write now
runs in one lagoon transaction with the event emitted inside it.
2026-10-27 15:35:07 +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 inside the write's transaction, so it
// reaches subscribers once the write commits. 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)
}