Files
sm-journal-plugin/controllers/api/record_events.go
Jakub Zych a6fc7cd954 fix: fire RecordSaved for tags the post API creates by name
journal-tag is a content type, but tags created on the fly from names in a
post write fired no event, so SEO and sitemap subscribers only heard of them
from a later bulk job. Each created tag now fires a RecordSaved with Created
inside the post write's transaction.
2026-10-27 15:37:02 +01:00

192 lines
6.4 KiB
Go

package api
import (
"context"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"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,
}
}
// tagSavedEvent is the RecordSaved of a tag a post write created by name
// (journal-tag is a content type, D-102): Created, with its name and slug.
func tagSavedEvent(ctx context.Context, tag *models.Tag) pact.RecordSaved {
locale, _ := towel.Locale(ctx)
return pact.RecordSaved{
Model: tag.MorphName(),
ID: strconv.FormatUint(uint64(tag.ID), 10),
Locale: locale,
Created: true,
Changes: []pact.RecordChange{
{Attribute: "name", New: tag.Name},
{Attribute: "slug", New: tag.Slug},
},
}
}
// submittedTranslations lists, per locale, the translated fields a request
// body submits with a value, in translatedEventFields order. content_html is
// listed whenever a non-blank content is, as the write derives it from the
// content (classes.FormatTranslation): the snapshot before the write and the
// event after it then cover the same fields, whether or not the write has
// already added content_html to the body.
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 {
v, has := attrs[field]
if (has && v != nil) || (field == "content_html" && derivesContentHTML(attrs)) {
out[locale] = append(out[locale], field)
}
}
}
return out
}
// derivesContentHTML reports whether a translation's attributes carry a
// content the write turns into content_html (a non-blank value).
func derivesContentHTML(attrs map[string]any) bool {
content, has := attrs["content"]
return has && content != nil && strings.TrimSpace(fmt.Sprint(content)) != ""
}
// 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)
}