From 57a347c97bafcc99fb83f09d5ddc1bfd8df32280 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 13:20:39 +0200 Subject: [PATCH] feat(api): list an author's published posts at /authors/{author}/posts - AuthorPosts reuses the /posts published predicate, per_page clamp, sort allow-list and row serializer - Bearer tokens are ignored; drafts and scheduled posts never appear - Both author routes share one resolve-or-respond helper for identical 404/500 paths - Document query keys, envelope and 404 in README --- README.md | 19 ++++- controllers/api/authors.go | 35 ++++++++++ journal_api_authors_test.go | 134 ++++++++++++++++++++++++++++++++++++ routes.go | 1 + 4 files changed, 188 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a7dbd5..91ae23c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`/`mlmarkdown`. `classes.FormatHTML` regenerates `content_html` on admin save. - **Categories and tags.** `models.Category` is translatable (name, description, slug). Fillable is name, slug, code, description, parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`. - **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`, and `/authors/{author}`. Writes under the same prefix require a cabana backend JWT (`aud=backend`). +- **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`). - **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`. - **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON. @@ -77,6 +77,7 @@ GET /_journal/api/v1/categories GET /_journal/api/v1/tags GET /_journal/api/v1/rss GET /_journal/api/v1/authors/jane-doe +GET /_journal/api/v1/authors/jane-doe/posts?page=2 ``` ### Public author API @@ -100,6 +101,22 @@ An unknown author, a login without published posts, a soft-deleted user and a ma {"error":"Author not found"} ``` +`GET /_journal/api/v1/authors/{author}/posts` lists that author's published posts (`published` true and `published_at` in the past), resolving `{author}` the same way. Rows have the same shape as `GET /_journal/api/v1/posts` rows. Bearer tokens are ignored, so an editor also sees published posts only. Query keys: + +| Key | Default | Notes | +|-----|---------|-------| +| `page` | `1` | Values below 1 or non-numeric fall back to page 1. | +| `per_page` | `9` | Clamped to 1..30. | +| `sort` | `published_at desc` | Same columns as `/posts`: `published_at`, `created_at`, `updated_at`, `title`, `id`, each `asc` or `desc`. | + +The response uses the `/posts` pagination envelope (rows trimmed here): + +```json +{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}} +``` + +An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route. + Editors create a post with a cabana backend JWT (audience `backend`): ```http diff --git a/controllers/api/authors.go b/controllers/api/authors.go index 90031ca..dce8278 100644 --- a/controllers/api/authors.go +++ b/controllers/api/authors.go @@ -8,6 +8,7 @@ import ( "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/wire" "gorm.io/gorm" ) @@ -129,3 +130,37 @@ func AuthorShow(app *backpack.App) http.HandlerFunc { wire.WriteJSON(w, http.StatusOK, map[string]any{"data": authorProfile(row)}) } } + +// AuthorPosts is GET /_journal/api/v1/authors/{author}/posts: that author's +// published posts, paginated like GET /posts. Bearer tokens are ignored. +func AuthorPosts(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + db, author, ok := authorOrRespond(app, w, r) + if !ok { + return + } + perPage := clampPerPage(r.URL.Query().Get("per_page")) + page := max(1, atoiDefault(r.URL.Query().Get("page"), 1)) + + q := applyPublished(db.Model(&models.Post{}). + Preload("User").Preload("Categories").Preload("Tags")). + Where("user_id = ?", author.ID) + + var total int64 + if err := q.Count(&total).Error; err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + order := listOrder(strings.TrimSpace(r.URL.Query().Get("sort")), false) + var posts []models.Post + if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + rows := make([]map[string]any, 0, len(posts)) + for i := range posts { + rows = append(rows, serializePost(&posts[i], db)) + } + wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)) + } +} diff --git a/journal_api_authors_test.go b/journal_api_authors_test.go index 6c593a4..90aa8a1 100644 --- a/journal_api_authors_test.go +++ b/journal_api_authors_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" ) @@ -149,3 +150,136 @@ func TestJournalAuthorProfile(t *testing.T) { t.Fatalf("fresh-author profile = %v", data) } } + +type authorPostsPage struct { + Data []map[string]any `json:"data"` + Meta struct { + CurrentPage int `json:"current_page"` + LastPage int `json:"last_page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + } `json:"meta"` +} + +func (p authorPostsPage) slugs() []string { + out := make([]string, 0, len(p.Data)) + for _, row := range p.Data { + slug, _ := row["slug"].(string) + out = append(out, slug) + } + return out +} + +func getAuthorPosts(t *testing.T, env *adminEnv, path, token string) authorPostsPage { + t.Helper() + rec := env.call(t, http.MethodGet, path, token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200: %s", path, rec.Code, rec.Body.String()) + } + var page authorPostsPage + if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil { + t.Fatalf("GET %s body %s: %v", path, rec.Body.String(), err) + } + if page.Data == nil { + t.Fatalf("GET %s: data is not an array: %s", path, rec.Body.String()) + } + return page +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} + +func expectMeta(t *testing.T, label string, p authorPostsPage, current, last, perPage int, total int64) { + t.Helper() + m := p.Meta + if m.CurrentPage != current || m.LastPage != last || m.PerPage != perPage || m.Total != total { + t.Fatalf("%s meta = %+v, want {current_page:%d last_page:%d per_page:%d total:%d}", label, m, current, last, perPage, total) + } +} + +func TestJournalAuthorPosts(t *testing.T) { + env := newPostsEnv(t) + now := time.Now().UTC() + at := func(d time.Duration) *time.Time { v := now.Add(d); return &v } + + janeID := insertBackendUser(t, env, "Jane", "Doe", "jdoe", "jane-doe", false) + insertAuthorPost(t, env, janeID, "a1", true, at(-3*time.Hour)) + insertAuthorPost(t, env, janeID, "a2", true, at(-2*time.Hour)) + insertAuthorPost(t, env, janeID, "a3", true, at(-time.Hour)) + insertAuthorPost(t, env, janeID, "a-draft", false, at(-30*time.Minute)) + insertAuthorPost(t, env, janeID, "a-future", true, at(time.Hour)) + bobID := insertBackendUser(t, env, "Bob", "Brown", "bob", "", false) + insertAuthorPost(t, env, bobID, "b1", true, at(-90*time.Minute)) + carolID := insertBackendUser(t, env, "Carol", "Smith", "carol", "", false) + insertAuthorPost(t, env, carolID, "carol-draft", false, nil) + insertBackendUser(t, env, "Fresh", "Author", "fresh", "fresh-author", false) + + base := authorAPIPrefix + "jane-doe/posts" + page := getAuthorPosts(t, env, base, "") + if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) { + t.Fatalf("default order = %v, want [a3 a2 a1]", got) + } + expectMeta(t, "default", page, 1, 1, 9, 3) + + // Each row has the GET /posts row shape. + all := getAuthorPosts(t, env, "/_journal/api/v1/posts", "") + var listRow map[string]any + for _, row := range all.Data { + if row["slug"] == "a3" { + listRow = row + } + } + if listRow == nil { + t.Fatalf("a3 missing from GET /posts: %v", all.slugs()) + } + if got, want := sortedKeys(page.Data[0]), sortedKeys(listRow); !slices.Equal(got, want) { + t.Fatalf("author row keys = %v, want /posts row keys %v", got, want) + } + author, _ := page.Data[0]["author"].(map[string]any) + if author == nil || author["login"] != "jdoe" { + t.Fatalf("row author = %v, want login jdoe", page.Data[0]["author"]) + } + + page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "") + if got := page.slugs(); !slices.Equal(got, []string{"a1"}) { + t.Fatalf("page 2 = %v, want [a1]", got) + } + expectMeta(t, "page 2", page, 2, 2, 2, 3) + + page = getAuthorPosts(t, env, base+"?per_page=100", "") + expectMeta(t, "per_page=100", page, 1, 1, 30, 3) + + page = getAuthorPosts(t, env, base+"?page=abc", "") + expectMeta(t, "page=abc", page, 1, 1, 9, 3) + + page = getAuthorPosts(t, env, base+"?sort=published_at+asc", "") + if got := page.slugs(); !slices.Equal(got, []string{"a1", "a2", "a3"}) { + t.Fatalf("asc order = %v, want [a1 a2 a3]", got) + } + + // An editor token is ignored: drafts and scheduled posts stay hidden. + token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts) + page = getAuthorPosts(t, env, base+"?published=0", token) + if got := page.slugs(); !slices.Equal(got, []string{"a3", "a2", "a1"}) { + t.Fatalf("editor list = %v, want published only [a3 a2 a1]", got) + } + expectMeta(t, "editor", page, 1, 1, 9, 3) + + page = getAuthorPosts(t, env, authorAPIPrefix+"jdoe/posts", "") + expectMeta(t, "login", page, 1, 1, 9, 3) + + page = getAuthorPosts(t, env, authorAPIPrefix+"fresh-author/posts", "") + if len(page.Data) != 0 { + t.Fatalf("fresh-author rows = %v, want none", page.slugs()) + } + expectMeta(t, "fresh-author", page, 1, 1, 9, 0) + + expectAuthorNotFound(t, env, authorAPIPrefix+"nobody/posts") + expectAuthorNotFound(t, env, authorAPIPrefix+"carol/posts") +} diff --git a/routes.go b/routes.go index d4c559a..094b69e 100644 --- a/routes.go +++ b/routes.go @@ -20,6 +20,7 @@ func (p *Plugin) Routes(r pact.Router) error { g.Get("/tags", apicontrollers.Tags(p.app)) g.Get("/rss", apicontrollers.RSS(p.app)) g.Get("/authors/{author}", apicontrollers.AuthorShow(p.app)) + g.Get("/authors/{author}/posts", apicontrollers.AuthorPosts(p.app)) }) r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) { g.Post("/media/upload", apicontrollers.UploadMedia(p.app))