diff --git a/README.md b/README.md index 0652743..be882f9 100644 --- a/README.md +++ b/README.md @@ -23,7 +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`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors). - **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`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. 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. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. 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. +- **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. A tag `POST` or `PUT` creates from a name in `tags` fires its own RecordSaved with `Created`. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. 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 e762973..cb1f45b 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -227,13 +227,17 @@ func Store(app *backpack.App) http.HandlerFunc { if err := tx.Create(post).Error; err != nil { return err } - if err := applyPostAssocs(ctx, tx, post, body); err != nil { + tags, err := applyPostAssocs(ctx, tx, post, body) + if err != nil { return err } if err := loadPostAssocs(tx, post); err != nil { return err } cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, nil, post, body)) + for _, tag := range tags { + cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag)) + } return nil }) if err != nil { @@ -291,13 +295,17 @@ func Update(app *backpack.App) http.HandlerFunc { if err := tx.Save(post).Error; err != nil { return err } - if err := applyPostAssocs(ctx, tx, post, body); err != nil { + tags, err := applyPostAssocs(ctx, tx, post, body) + if err != nil { return err } if err := loadPostAssocs(tx, post); err != nil { return err } cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, before, post, body)) + for _, tag := range tags { + cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag)) + } return nil }) if err != nil { diff --git a/controllers/api/posts_helpers.go b/controllers/api/posts_helpers.go index 2332753..4ace51a 100644 --- a/controllers/api/posts_helpers.go +++ b/controllers/api/posts_helpers.go @@ -695,54 +695,59 @@ func asUintSlice(raw any) ([]uint, bool) { } } -func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) error { +// applyPostAssocs writes the post's categories, tags and translations the +// body submits. created are the tags the write created by name, for their +// record events. +func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) (created []models.Tag, err error) { if _, has := body["categories"]; has { ids, _ := asUintSlice(body["categories"]) if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil { - return err + return nil, err } for _, id := range ids { if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil { - return err + return nil, err } } } if _, has := body["tags"]; has { - ids, err := resolveTagIDs(db, body["tags"]) + var ids []uint + ids, created, err = resolveTagIDs(db, body["tags"]) if err != nil { - return err + return nil, err } if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil { - return err + return nil, err } for _, id := range ids { if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil { - return err + return nil, err } } } if raw, has := body["translations"]; has { if err := syncTranslations(ctx, db, post, raw); err != nil { - return err + return nil, err } } - return nil + return created, nil } -func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) { +// resolveTagIDs turns the submitted tags (ids or names) into tag ids; a name +// no tag has yet creates the tag, returned in created. +func resolveTagIDs(db *gorm.DB, raw any) (ids []uint, created []models.Tag, err error) { items, ok := raw.([]any) if !ok { if raw == nil { - return nil, nil + return nil, nil, nil } - return nil, fmt.Errorf("tags must be an array") + return nil, nil, fmt.Errorf("tags must be an array") } - var ids []uint for _, item := range items { if id, ok := asUint(item); ok { var n int64 if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil { - return nil, err + return nil, nil, err } if n > 0 { ids = append(ids, id) @@ -765,11 +770,12 @@ func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) { } tag = models.Tag{Name: name, Slug: slug} if err := db.Create(&tag).Error; err != nil { - return nil, err + return nil, nil, err } ids = append(ids, tag.ID) + created = append(created, tag) } - return ids, nil + return ids, created, nil } func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error { diff --git a/controllers/api/record_events.go b/controllers/api/record_events.go index 95568b1..8a2b142 100644 --- a/controllers/api/record_events.go +++ b/controllers/api/record_events.go @@ -122,6 +122,22 @@ func postSavedEvent(ctx context.Context, db *gorm.DB, before *postSnapshot, post } } +// 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 diff --git a/record_events_test.go b/record_events_test.go index 5c466b5..75b2e59 100644 --- a/record_events_test.go +++ b/record_events_test.go @@ -258,6 +258,33 @@ func TestAPIRecordEvents(t *testing.T) { t.Fatalf("pl content_html change = %+v (found %v); changes %+v", html, ok, got[0].ev.Changes) } + // A tag the write creates by name fires its own RecordSaved (WR-06). + l.reset() + env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{ + "tags": []any{"Fresh event tag"}, + }) + got = l.savedEvents() + tagMorph := (models.Tag{}).MorphName() + var tagEvent *savedEvent + for i := range got { + if got[i].ev.Model == tagMorph { + tagEvent = &got[i] + } + } + if len(got) != 2 || tagEvent == nil || !tagEvent.ev.Created || tagEvent.ev.ID == "" || tagEvent.key != classes.MenuTypeTag { + t.Fatalf("PUT with a new tag events = %+v, want the post and the created tag", got) + } + if c, ok := findChange(tagEvent.ev.Changes, "name", ""); !ok || c.New != "Fresh event tag" { + t.Fatalf("tag name change = %+v (found %v)", c, ok) + } + l.reset() + env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{ + "tags": []any{"Fresh event tag"}, + }) + if got = l.savedEvents(); len(got) != 1 { + t.Fatalf("PUT with an existing tag events = %+v, want only the post", got) + } + // A write that fails after the row is saved rolls the row back and // fires nothing (WR-04). l.reset()