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:
2026-10-26 22:35:08 +01:00
parent 08ab15338c
commit 4b3a8bb049
4 changed files with 258 additions and 0 deletions

View File

@@ -23,6 +23,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) with the framework's menu item type registry, so menus and sitemaps can point at posts and categories. See [menu item types](#menu-item-types).
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal.
- **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.

View File

@@ -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})
}
}

View 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)
}

View File

@@ -2,6 +2,7 @@ package journal_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
@@ -163,3 +164,88 @@ func TestRecordSavedDescribesNewURL(t *testing.T) {
t.Fatalf("failed save fired %d events: %+v", len(got), got)
}
}
// TestAPIRecordEvents (D-102): the /_journal/api/v1 write handlers fire the
// same record events as the admin, so a slug changed through MCP reaches
// subscribers: POST fires RecordSaved with Created, PUT a slug change (the
// default locale on the row, a translated locale per locale), DELETE
// RecordDeleted. Controller is "" outside the admin forms.
func TestAPIRecordEvents(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t,
controllers.PermissionAccessPosts,
controllers.PermissionAccessPublish,
controllers.PermissionAccessOtherPosts,
)
l := listenRecords(t, env)
morph := (models.Post{}).MorphName()
rec := env.expect(t, http.StatusCreated, http.MethodPost, "/_journal/api/v1/posts", token, map[string]any{
"title": "API post",
"slug": "api-old",
"content": "Body",
"translations": map[string]any{"pl": map[string]any{"slug": "api-stary"}},
})
var created struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.Data.ID == 0 {
t.Fatalf("create body %s: %v", rec.Body.String(), err)
}
id := fmt.Sprint(created.Data.ID)
got := l.savedEvents()
if len(got) != 1 {
t.Fatalf("POST events = %+v, want one RecordSaved", got)
}
ev := got[0].ev
if ev.Controller != "" || ev.Model != morph || ev.ID != id || !ev.Created {
t.Fatalf("POST event = %+v", ev)
}
if c, ok := findChange(ev.Changes, "slug", ""); !ok || c.Old != nil || c.New != "api-old" {
t.Fatalf("POST slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if c, ok := findChange(ev.Changes, "slug", "pl"); !ok || c.Old != nil || c.New != "api-stary" {
t.Fatalf("POST pl slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if got[0].url != "/blog/api-old" {
t.Fatalf("POST described URL = %q", got[0].url)
}
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"slug": "api-new",
"translations": map[string]any{"pl": map[string]any{"slug": "api-nowy"}},
})
got = l.savedEvents()
if len(got) != 1 {
t.Fatalf("PUT events = %+v, want one RecordSaved", got)
}
ev = got[0].ev
if ev.Controller != "" || ev.Model != morph || ev.ID != id || ev.Created {
t.Fatalf("PUT event = %+v", ev)
}
if c, ok := findChange(ev.Changes, "slug", ""); !ok || c.Old != "api-old" || c.New != "api-new" {
t.Fatalf("PUT slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if c, ok := findChange(ev.Changes, "slug", "pl"); !ok || c.Old != "api-stary" || c.New != "api-nowy" {
t.Fatalf("PUT pl slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if _, ok := findChange(ev.Changes, "title", ""); ok {
t.Fatalf("unchanged title reported: %+v", ev.Changes)
}
if got[0].url != "/blog/api-new" {
t.Fatalf("PUT described URL = %q", got[0].url)
}
l.reset()
env.expect(t, http.StatusOK, http.MethodDelete, "/_journal/api/v1/posts/"+id, token, nil)
if saved := l.savedEvents(); len(saved) != 0 {
t.Fatalf("DELETE fired RecordSaved: %+v", saved)
}
deleted := l.deletedEvents()
if len(deleted) != 1 || deleted[0] != (pact.RecordDeleted{Model: morph, ID: id}) {
t.Fatalf("DELETE events = %+v", deleted)
}
}