Covers the MOPAC briefing/report surface (Redmine 495 Part A): category
list/create (create needs the admin-scoped key; current key 403s, typed
as ErrForbidden), topic create/list/latest/get, post create/update/get,
current-user identity probe, and a Do() JSON passthrough so unmodeled
endpoints need no client release. Key is env/constructor-only, never a
flag, never logged; errors classify via errors.Is. Fake-server unit
tests + containerized end-to-end smoke (redaction sweep included); live
reads verified against community.turnsys.com.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
138 lines
4.1 KiB
Go
138 lines
4.1 KiB
Go
// Topics: create (POST /posts.json — Discourse creates topics by posting
|
|
// the first post with a title), list per category (GET /c/<...>.json) and
|
|
// latest (GET /latest.json), fetch one (GET /t/<id>.json).
|
|
package discourse
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// TopicSummary is one row of a topic list.
|
|
type TopicSummary struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
CategoryID int `json:"category_id"`
|
|
PostsCount int `json:"posts_count"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Closed bool `json:"closed"`
|
|
Archived bool `json:"archived"`
|
|
}
|
|
|
|
// Topic is the full topic payload (GET /t/<id>.json); posts live under
|
|
// PostStream.Posts.
|
|
type Topic struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
CategoryID int `json:"category_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
Closed bool `json:"closed"`
|
|
Archived bool `json:"archived"`
|
|
PostStream struct {
|
|
Posts []Post `json:"posts"`
|
|
} `json:"post_stream"`
|
|
}
|
|
|
|
// CreateTopicRequest creates a topic: title + markdown body + category.
|
|
type CreateTopicRequest struct {
|
|
Title string `json:"title"`
|
|
Raw string `json:"raw"`
|
|
Category int `json:"category"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
// CreateTopicResult is the POST /posts.json response for a topic
|
|
// creation: the first post's id plus the new topic's id and slug.
|
|
type CreateTopicResult struct {
|
|
PostID int `json:"id"`
|
|
TopicID int `json:"topic_id"`
|
|
TopicSlug string `json:"topic_slug"`
|
|
}
|
|
|
|
// URL renders the topic's canonical URL on this instance.
|
|
func (r CreateTopicResult) URL(baseURL string) string {
|
|
if r.TopicID == 0 {
|
|
return ""
|
|
}
|
|
slug := r.TopicSlug
|
|
if slug == "" {
|
|
slug = "topic"
|
|
}
|
|
return strings.TrimSuffix(baseURL, "/") + "/t/" + slug + "/" + fmt.Sprint(r.TopicID)
|
|
}
|
|
|
|
// CreateTopic posts a new topic into the category and returns its ids.
|
|
func (c *Client) CreateTopic(ctx context.Context, req CreateTopicRequest) (*CreateTopicResult, error) {
|
|
if strings.TrimSpace(req.Title) == "" {
|
|
return nil, fmt.Errorf("%w: topic title is required", ErrInvalidRequest)
|
|
}
|
|
if req.Category == 0 {
|
|
return nil, fmt.Errorf("%w: topic category id is required", ErrInvalidRequest)
|
|
}
|
|
var out CreateTopicResult
|
|
if err := c.Post(ctx, "/posts.json", req, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
if out.TopicID == 0 {
|
|
return nil, fmt.Errorf("%w: topic created but response carries no topic_id", ErrMalformedResponse)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ListTopics returns the topic list of a category, addressed by id
|
|
// (GET /c/<id>.json) or slug (GET /c/<slug>.json). Pass slug="" with id>0
|
|
// or id=0 with a slug.
|
|
func (c *Client) ListTopics(ctx context.Context, id int, slug string) ([]TopicSummary, error) {
|
|
if id == 0 && slug == "" {
|
|
return nil, fmt.Errorf("%w: category id or slug is required", ErrInvalidRequest)
|
|
}
|
|
var path string
|
|
if id > 0 {
|
|
if slug != "" {
|
|
path = "/c/" + quotePathSegment(slug) + "/" + fmt.Sprint(id) + ".json"
|
|
} else {
|
|
path = "/c/" + fmt.Sprint(id) + ".json"
|
|
}
|
|
} else {
|
|
path = "/c/" + quotePathSegment(slug) + ".json"
|
|
}
|
|
var payload struct {
|
|
TopicList struct {
|
|
Topics []TopicSummary `json:"topics"`
|
|
} `json:"topic_list"`
|
|
}
|
|
if err := c.Get(ctx, path, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return payload.TopicList.Topics, nil
|
|
}
|
|
|
|
// LatestTopics returns the instance's latest topic list (GET /latest.json).
|
|
func (c *Client) LatestTopics(ctx context.Context) ([]TopicSummary, error) {
|
|
var payload struct {
|
|
TopicList struct {
|
|
Topics []TopicSummary `json:"topics"`
|
|
} `json:"topic_list"`
|
|
}
|
|
if err := c.Get(ctx, "/latest.json", &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
return payload.TopicList.Topics, nil
|
|
}
|
|
|
|
// GetTopic fetches one topic by id, posts included.
|
|
func (c *Client) GetTopic(ctx context.Context, id int) (*Topic, error) {
|
|
if id <= 0 {
|
|
return nil, fmt.Errorf("%w: topic id must be > 0", ErrInvalidRequest)
|
|
}
|
|
var topic Topic
|
|
if err := c.Get(ctx, "/t/"+fmt.Sprint(id)+".json", &topic); err != nil {
|
|
return nil, err
|
|
}
|
|
return &topic, nil
|
|
}
|