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:
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user