diff --git a/README.md b/README.md index 21b46b0..816dadd 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/controllers/api/posts.go b/controllers/api/posts.go index 01975a4..cdaa7c9 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -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}) } } diff --git a/controllers/api/record_events.go b/controllers/api/record_events.go new file mode 100644 index 0000000..9bac33d --- /dev/null +++ b/controllers/api/record_events.go @@ -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) +} diff --git a/record_events_test.go b/record_events_test.go index 7b73fca..0375a12 100644 --- a/record_events_test.go +++ b/record_events_test.go @@ -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) + } +}