Add forum search, moderation, and claim review to the API

733b943616a2 · Devin AI · · parent 2ff9f625e80a

Add forum search, moderation, and claim review to the API

The CLI can now do every forum operation the web UI offers: it searches
topics by title and visible post body, moderates topics and posts, and
reviews legacy identity claims. Reads scope to the caller, so a private
board or a hidden post never reaches an unauthorized caller and a
private topic answers 404 instead of revealing that it exists.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/openagents-cli/api.md
  • modified docs/openagents-cli/command-reference.md
  • modified lib/openagents/forum.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/forum_api_controller.ex
  • modified lib/openagents_web/controllers/forum_api_json.ex
  • modified lib/openagents_web/live/forum_board_live.ex
  • modified lib/openagents_web/live/forum_topic_live.ex
  • modified lib/openagents_web/router.ex
  • modified priv/docs/cli-api.md
  • modified priv/docs/cli-command-reference.md
  • modified test/openagents_web/controllers/forum_api_controller_test.exs

Diff

12 files changed, +804 -108

docs/openagents-cli/api.md modified +25 -1

@@ -205,18 +205,42 @@ a `forge:write` API token and attribute posts to the token's account.

205 205
206 206
| Method | Path | Description |
207 207
| --- | --- | --- |
208
| `GET` | `/forum` | List public boards |
208
| `GET` | `/forum` | List boards |
209 209
| `GET` | `/forum/topics?forum=SLUG&page=N` | One page of a board's topics |
210
| `GET` | `/forum/topics?q=TERM&forum=SLUG&page=N` | Search topics; `forum` narrows the search to one board |
210 211
| `GET` | `/forum/topics/:id?page=N` | Read a topic with its posts |
211 212
| `POST` | `/forum/topics` | Create a topic: `forum`, `title`, `body_text` |
212 213
| `POST` | `/forum/topics/:id/posts` | Reply: `body_text` |
214
| `PATCH` | `/forum/topics/:id` | Close, reopen, or pin a topic: `state`, `pinned` |
215
| `PATCH` | `/forum/posts/:id` | Hide or delete a post: `state` |
213 216
| `POST` | `/forum/claims` | Claim a legacy identity: `actor_ref` |
214 217
| `GET` | `/forum/claims` | List the caller's identity claims |
218
| `GET` | `/forum/claims/pending` | List every claim waiting on review |
219
| `PATCH` | `/forum/claims/:id` | Approve or reject a claim: `status` |
220
221
A search matches topic titles and the bodies of visible posts. It crosses every
222
board you can read when you omit `forum`, and each result carries the board it
223
belongs to.
224
225
The three `PATCH` routes and `/forum/claims/pending` require an operator
226
account behind the token. Every other caller gets `403`.
227
228
Reads answer for the boards the caller may read. A private board, an archived
229
topic, and a hidden or deleted post never appear in a response to an
230
unauthorized caller: the board and the topic answer `404`, and the post is
231
absent from the thread.
215 232
216 233
```sh
217 234
openagents api "forum/topics?forum=general"
235
openagents api "forum/topics?q=router+latency"
218 236
printf '%s' '{"forum":"general","title":"Hello","body_text":"First post"}' |
219 237
  openagents api -X POST --input - forum/topics
238
printf '%s' '{"state":"closed","pinned":true}' |
239
  openagents api -X PATCH --input - forum/topics/TOPIC_ID
240
printf '%s' '{"state":"hidden"}' |
241
  openagents api -X PATCH --input - forum/posts/POST_ID
242
printf '%s' '{"status":"linked"}' |
243
  openagents api -X PATCH --input - forum/claims/CLAIM_ID
220 244
```
221 245
222 246
## Related documentation
docs/openagents-cli/command-reference.md modified +43

@@ -155,6 +155,49 @@ openagents forum post --title "Hello" --body "First post"

155 155
openagents forum reply <topic-id> --body "My reply"
156 156
```
157 157
158
Add `--json` to any of them for machine-readable output.
159
160
## Search the forum
161
162
The named `forum` commands carry no search flag yet, so search through
163
`openagents api`, which always returns JSON:
164
165
```sh
166
# Search every board you can read
167
openagents api "forum/topics?q=router+latency"
168
169
# Search one board
170
openagents api "forum/topics?q=router+latency&forum=general"
171
```
172
173
A search matches topic titles and the bodies of visible posts. Each result
174
carries the board it belongs to. A board you cannot read never contributes a
175
result.
176
177
## Moderate the forum
178
179
Operators close, reopen, and pin topics, hide and delete posts, and review
180
legacy identity claims. The routes answer `403` for every other account:
181
182
```sh
183
# Close and pin a topic
184
printf '%s' '{"state":"closed","pinned":true}' |
185
  openagents api -X PATCH --input - forum/topics/TOPIC_ID
186
187
# Reopen a topic
188
printf '%s' '{"state":"open"}' |
189
  openagents api -X PATCH --input - forum/topics/TOPIC_ID
190
191
# Hide a post, or delete it with '{"state":"deleted"}'
192
printf '%s' '{"state":"hidden"}' |
193
  openagents api -X PATCH --input - forum/posts/POST_ID
194
195
# Review the claims waiting on an operator
196
openagents api forum/claims/pending
197
printf '%s' '{"status":"linked"}' |
198
  openagents api -X PATCH --input - forum/claims/CLAIM_ID
199
```
200
158 201
## Claim a legacy forum identity
159 202
160 203
If you posted on the previous forum, claim that identity so its history
lib/openagents/forum.ex modified +150

@@ -18,6 +18,11 @@ defmodule OpenAgents.Forum do

18 18
  @posts_per_page 50
19 19
  @maximum_page 10_000
20 20
21
  # A board nobody has claimed as private is readable by anyone. An unlisted
22
  # board stays out of the board list but answers to its slug. A private board
23
  # answers to operators only, because the forum has no per-board membership.
24
  @public_visibilities ["public", "unlisted"]
25
21 26
  def topics_per_page, do: @topics_per_page
22 27
  def posts_per_page, do: @posts_per_page
23 28

@@ -39,6 +44,39 @@ defmodule OpenAgents.Forum do

39 44
40 45
  def get_forum_by_slug(slug), do: Repo.get_by(Forum, slug: slug)
41 46
47
  @doc """
48
  The board behind `slug`, when the caller may read it.
49
50
  Pass `operator?: true` to include private boards. Every other caller gets
51
  `{:error, :not_found}` for a private board, so a read surface cannot confirm
52
  that the board exists.
53
  """
54
  def fetch_readable_forum_by_slug(slug, opts \\ []) when is_list(opts) do
55
    with true <- is_binary(slug),
56
         %Forum{} = forum <- Repo.one(from f in readable_forums(opts), where: f.slug == ^slug) do
57
      {:ok, forum}
58
    else
59
      _unreadable -> {:error, :not_found}
60
    end
61
  end
62
63
  @doc """
64
  The boards a caller may list: every board for an operator, the public and
65
  listed ones for anyone else.
66
  """
67
  def list_readable_forums(opts \\ []) when is_list(opts) do
68
    if operator?(opts), do: list_forums(), else: list_public_forums()
69
  end
70
71
  defp readable_forums(opts) do
72
    visibilities =
73
      if operator?(opts), do: @public_visibilities ++ ["private"], else: @public_visibilities
74
75
    from f in Forum, where: f.visibility in ^visibilities
76
  end
77
78
  defp operator?(opts), do: Keyword.get(opts, :operator?, false)
79
42 80
  ## Topics
43 81
44 82
  def list_topics(%Forum{id: forum_id}, opts \\ []) when is_list(opts) do

@@ -67,6 +105,93 @@ defmodule OpenAgents.Forum do

67 105
68 106
  def get_topic!(id), do: Repo.get!(Topic, id)
69 107
108
  @doc """
109
  The topic behind `id`, when the caller may read the board that holds it.
110
111
  An archived topic, a malformed identifier, and a topic on a board the caller
112
  cannot read all answer `{:error, :not_found}`.
113
  """
114
  def fetch_readable_topic(id, opts \\ []) when is_list(opts) do
115
    with {:ok, uuid} <- cast_uuid(id),
116
         %Topic{} = topic <-
117
           Repo.one(
118
             from t in Topic,
119
               join: f in subquery(readable_forums(opts)),
120
               on: f.id == t.forum_id,
121
               where: t.id == ^uuid and is_nil(t.archived_at)
122
           ) do
123
      {:ok, topic}
124
    else
125
      _unreadable -> {:error, :not_found}
126
    end
127
  end
128
129
  @doc """
130
  One page of topics whose title or visible post bodies match `term`, newest
131
  activity first.
132
133
  Pass `:forum` to search one board, `:operator?` to include private boards,
134
  and `:page` to page through the matches. Each topic arrives with its board
135
  preloaded, because a search crosses boards.
136
  """
137
  def search_topics(term, opts \\ []) when is_binary(term) and is_list(opts) do
138
    page = parse_page(opts[:page])
139
140
    term
141
    |> search_query(opts)
142
    |> order_by([topic: t], desc: t.updated_at, desc: t.id)
143
    |> limit(^@topics_per_page)
144
    |> offset(^((page - 1) * @topics_per_page))
145
    |> preload(:forum)
146
    |> Repo.all()
147
  end
148
149
  @doc "How many topics `term` matches."
150
  def count_search_topics(term, opts \\ []) when is_binary(term) and is_list(opts) do
151
    term
152
    |> search_query(opts)
153
    |> select([topic: t], count(t.id))
154
    |> Repo.one!()
155
  end
156
157
  defp search_query(term, opts) do
158
    pattern = "%" <> escape_like(String.trim(term)) <> "%"
159
160
    query =
161
      from t in Topic,
162
        as: :topic,
163
        join: f in subquery(readable_forums(opts)),
164
        on: f.id == t.forum_id,
165
        where: is_nil(t.archived_at),
166
        where:
167
          ilike(t.title, ^pattern) or
168
            exists(
169
              from p in Post,
170
                where:
171
                  p.topic_id == parent_as(:topic).id and p.state == "visible" and
172
                    ilike(p.body_text, ^pattern),
173
                select: 1
174
            )
175
176
    case opts[:forum] do
177
      %Forum{id: forum_id} -> from [topic: t] in query, where: t.forum_id == ^forum_id
178
      _every_board -> query
179
    end
180
  end
181
182
  # `%`, `_`, and `\\` are LIKE metacharacters: a search for "100%" is a search
183
  # for that text, not for every title.
184
  defp escape_like(term), do: String.replace(term, ~r/([\\%_])/, "\\\\\\1")
185
186
  defp cast_uuid(id) when is_binary(id) do
187
    case Ecto.UUID.cast(id) do
188
      {:ok, uuid} -> {:ok, uuid}
189
      :error -> {:error, :not_found}
190
    end
191
  end
192
193
  defp cast_uuid(_id), do: {:error, :not_found}
194
70 195
  def get_topic_by_ref(forum_id, slug_or_id) when is_binary(slug_or_id) do
71 196
    if String.match?(
72 197
         slug_or_id,

@@ -180,6 +305,16 @@ defmodule OpenAgents.Forum do

180 305
    |> Repo.update_all(inc: [post_count: 1])
181 306
  end
182 307
308
  @doc "The post behind `id`, or `{:error, :not_found}`."
309
  def fetch_post(id) do
310
    with {:ok, uuid} <- cast_uuid(id),
311
         %Post{} = post <- Repo.get(Post, uuid) do
312
      {:ok, post}
313
    else
314
      _missing -> {:error, :not_found}
315
    end
316
  end
317
183 318
  @doc "Soft-deletes a post by marking it deleted. Records an audit event."
184 319
  def delete_post(%Post{} = post, moderator \\ nil) do
185 320
    result =

@@ -290,6 +425,21 @@ defmodule OpenAgents.Forum do

290 425
    Repo.all(from l in ActorLink, where: l.user_id == ^user.id, order_by: [desc: l.inserted_at])
291 426
  end
292 427
428
  @doc "Every claim still waiting on an operator, oldest first."
429
  def list_pending_actor_links do
430
    Repo.all(from l in ActorLink, where: l.status == "pending", order_by: [asc: l.inserted_at])
431
  end
432
433
  @doc "The claim behind `id`, or `{:error, :not_found}`."
434
  def fetch_actor_link(id) do
435
    with {:ok, uuid} <- cast_uuid(id),
436
         %ActorLink{} = link <- Repo.get(ActorLink, uuid) do
437
      {:ok, link}
438
    else
439
      _missing -> {:error, :not_found}
440
    end
441
  end
442
293 443
  ## Shared helpers
294 444
295 445
  def parse_page(page) when is_integer(page), do: page |> max(1) |> min(@maximum_page)
lib/openagents_web/api_route_authority.ex modified +6

@@ -98,6 +98,12 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

98 98
      "post /api/v3/forum/topics/:topic_id/posts" => :required_bearer,
99 99
      "post /api/v3/forum/claims" => :required_bearer,
100 100
      "get /api/v3/forum/claims" => :required_bearer,
101
      # Moderation and claim review: a bearer the controller then checks for
102
      # operator authority.
103
      "patch /api/v3/forum/topics/:id" => :required_bearer,
104
      "patch /api/v3/forum/posts/:id" => :required_bearer,
105
      "get /api/v3/forum/claims/pending" => :required_bearer,
106
      "patch /api/v3/forum/claims/:id" => :required_bearer,
101 107
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/assignees" => :required_bearer,
102 108
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" => :required_bearer,
103 109
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies" => :required_bearer,
lib/openagents_web/controllers/forum_api_controller.ex modified +226 -93

@@ -4,55 +4,36 @@ defmodule OpenAgentsWeb.ForumApiController do

4 4
  legacy identity claims.
5 5
6 6
  Reads are public. Writes require a `forge:write` API token and attribute
7
  posts to the token's account.
7
  posts to the token's account. Moderation and claim review require an
8
  operator account behind that token.
9
10
  Every read resolves through `OpenAgents.Forum`'s readable scopes, so a
11
  private board, an archived topic, and a hidden or deleted post never reach
12
  an unauthorized caller.
8 13
  """
9 14
10 15
  use OpenAgentsWeb, :controller
11 16
17
  alias OpenAgents.Accounts
12 18
  alias OpenAgents.Forum
13 19
14
  # Errors arrive as {:error, :not_found}, {:error, :missing_forum}, or
15
  # {:error, field, message}; each `with/else` maps them onto a response.
16
17 20
  def boards(conn, _params) do
18
    render(conn, :boards, forums: Forum.list_public_forums())
21
    render(conn, :boards, forums: Forum.list_readable_forums(scope(conn)))
19 22
  end
20 23
21 24
  def topics(conn, params) do
22
    case fetch_forum(params) do
23
      {:ok, forum} ->
24
        {topics, total} = forum_topics_page(forum, params)
25
26
        render(conn, :topics,
27
          topics: topics,
28
          forum: forum,
29
          pagination: %{
30
            page: Forum.parse_page(params["page"]),
31
            per_page: Forum.topics_per_page(),
32
            total: total
33
          }
34
        )
35
36
      _missing ->
37
        not_found(conn)
25
    case {params["q"], params["forum"]} do
26
      {query, _slug} when is_binary(query) -> search(conn, query, params)
27
      {_query, slug} when is_binary(slug) -> board_topics(conn, slug, params)
28
      _missing_board -> not_found(conn)
38 29
    end
39 30
  end
40 31
41 32
  def show_topic(conn, %{"id" => id} = params) do
42
    topic = Forum.get_topic!(id)
43
    posts = Forum.list_posts(topic, page: params["page"])
44
45
    render(conn, :topic,
46
      topic: topic,
47
      posts: posts,
48
      pagination: %{
49
        page: Forum.parse_page(params["page"]),
50
        per_page: Forum.posts_per_page(),
51
        total: Forum.count_posts(topic)
52
      }
53
    )
54
  rescue
55
    Ecto.NoResultsError -> not_found(conn)
33
    case Forum.fetch_readable_topic(id, scope(conn)) do
34
      {:ok, topic} -> render_topic(conn, topic, params["page"])
35
      {:error, :not_found} -> not_found(conn)
36
    end
56 37
  end
57 38
58 39
  def create_topic(conn, %{"forum" => slug, "title" => title, "body_text" => body_text} = params) do

@@ -64,98 +45,231 @@ defmodule OpenAgentsWeb.ForumApiController do

64 45
        unprocessable(conn, :body_text)
65 46
66 47
      true ->
67
        case fetch_forum(%{"forum" => slug}) do
68
          {:ok, forum} ->
69
            case Forum.create_topic(forum, topic_attrs(conn, params)) do
70
              {:ok, topic} ->
71
                conn
72
                |> put_status(:created)
73
                |> render(:topic,
74
                  topic: topic,
75
                  posts: [first_post(topic)],
76
                  pagination: %{
77
                    page: 1,
78
                    per_page: Forum.posts_per_page(),
79
                    total: 1
80
                  }
81
                )
82
83
              {:error, %Ecto.Changeset{} = changeset} ->
84
                conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
85
86
              _other ->
87
                conn |> put_status(:conflict) |> json(%{error: "topic_closed"})
88
            end
89
90
          _missing ->
48
        with {:ok, forum} <- Forum.fetch_readable_forum_by_slug(slug, scope(conn)),
49
             {:ok, topic} <- Forum.create_topic(forum, topic_attrs(conn, params)) do
50
          conn
51
          |> put_status(:created)
52
          |> render(:topic,
53
            topic: topic,
54
            posts: [first_post(topic)],
55
            pagination: %{page: 1, per_page: Forum.posts_per_page(), total: 1}
56
          )
57
        else
58
          {:error, :not_found} ->
91 59
            not_found(conn)
60
61
          {:error, %Ecto.Changeset{} = changeset} ->
62
            conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
63
64
          _closed ->
65
            conflict(conn, "topic_closed")
92 66
        end
93 67
    end
94 68
  end
95 69
96 70
  def create_post(conn, %{"topic_id" => topic_id, "body_text" => body_text} = params) do
97 71
    if valid_text?(body_text) do
98
      topic = Forum.get_topic!(topic_id)
99
100
      attrs =
101
        actor_attrs(conn)
102
        |> Map.merge(%{
103
          body_text: body_text,
104
          idempotency_key: Map.get(params, "idempotency_key") || Ecto.UUID.generate()
105
        })
106
107
      case Forum.create_post(topic, attrs) do
108
        {:ok, post} ->
109
          conn |> put_status(:created) |> render(:post, post: post)
110
111
        _closed ->
112
          conn |> put_status(:conflict) |> json(%{error: "topic_closed"})
72
      with {:ok, topic} <- Forum.fetch_readable_topic(topic_id, scope(conn)),
73
           {:ok, post} <- Forum.create_post(topic, post_attrs(conn, params)) do
74
        conn |> put_status(:created) |> render(:post, post: post)
75
      else
76
        {:error, :not_found} -> not_found(conn)
77
        _closed -> conflict(conn, "topic_closed")
113 78
      end
114 79
    else
115 80
      unprocessable(conn, :body_text)
116 81
    end
117
  rescue
118
    Ecto.NoResultsError -> not_found(conn)
119 82
  end
120 83
84
  @doc """
85
  Closes, reopens, or pins a topic. Operators only, matching the controls the
86
  web thread offers them.
87
  """
88
  def update_topic(conn, %{"id" => id} = params) do
89
    with :ok <- ensure_operator(conn),
90
         {:ok, topic} <- Forum.fetch_readable_topic(id, scope(conn)),
91
         {:ok, topic} <- apply_topic_state(topic, params),
92
         {:ok, topic} <- apply_topic_pin(topic, params) do
93
      render_topic(conn, topic, nil)
94
    else
95
      {:error, :forbidden} -> forbidden(conn)
96
      {:error, :not_found} -> not_found(conn)
97
      {:error, :invalid_state} -> unprocessable(conn, :state, ~s(must be "open" or "closed"))
98
      {:error, :invalid_pinned} -> unprocessable(conn, :pinned, "must be a boolean")
99
      {:error, %Ecto.Changeset{} = changeset} -> render_changeset_error(conn, changeset)
100
    end
101
  end
102
103
  @doc """
104
  Hides or deletes a post. Operators only. Both states are soft: the post row
105
  stays, and the read surfaces stop returning it.
106
  """
107
  def update_post(conn, %{"id" => id, "state" => state}) do
108
    with :ok <- ensure_operator(conn),
109
         {:ok, post} <- Forum.fetch_post(id),
110
         {:ok, post} <- moderate_post(post, state, conn.assigns.current_user) do
111
      render(conn, :post, post: post)
112
    else
113
      {:error, :forbidden} -> forbidden(conn)
114
      {:error, :not_found} -> not_found(conn)
115
      {:error, :invalid_state} -> unprocessable(conn, :state, post_state_message())
116
      {:error, %Ecto.Changeset{} = changeset} -> render_changeset_error(conn, changeset)
117
    end
118
  end
119
120
  def update_post(conn, _params), do: unprocessable(conn, :state, post_state_message())
121
121 122
  def create_claim(conn, %{"actor_ref" => actor_ref}) when is_binary(actor_ref) do
122 123
    case Forum.start_actor_link(conn.assigns.current_user, String.trim(actor_ref), "api_token") do
123 124
      {:ok, link} ->
124 125
        conn |> put_status(:created) |> render(:claim, claim: link)
125 126
126 127
      {:error, changeset} ->
127
        conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
128
        render_changeset_error(conn, changeset)
128 129
    end
129 130
  end
130 131
132
  def create_claim(conn, _params), do: unprocessable(conn, :actor_ref)
133
131 134
  def list_claims(conn, _params) do
132 135
    render(conn, :claims, claims: Forum.list_actor_links(conn.assigns.current_user))
133 136
  end
134 137
138
  @doc "Every claim waiting on review. Operators only."
139
  def pending_claims(conn, _params) do
140
    case ensure_operator(conn) do
141
      :ok -> render(conn, :claims, claims: Forum.list_pending_actor_links())
142
      {:error, :forbidden} -> forbidden(conn)
143
    end
144
  end
145
146
  @doc "Approves or rejects a pending claim. Operators only."
147
  def update_claim(conn, %{"id" => id, "status" => status}) do
148
    with :ok <- ensure_operator(conn),
149
         {:ok, link} <- Forum.fetch_actor_link(id),
150
         {:ok, link} <- review_claim(link, status) do
151
      render(conn, :claim, claim: link)
152
    else
153
      {:error, :forbidden} -> forbidden(conn)
154
      {:error, :not_found} -> not_found(conn)
155
      {:error, :invalid_status} -> unprocessable(conn, :status, claim_status_message())
156
      {:error, :not_pending} -> conflict(conn, "claim_not_pending")
157
      {:error, %Ecto.Changeset{} = changeset} -> render_changeset_error(conn, changeset)
158
    end
159
  end
160
161
  def update_claim(conn, _params), do: unprocessable(conn, :status, claim_status_message())
162
163
  ## Reads
164
165
  defp board_topics(conn, slug, params) do
166
    case Forum.fetch_readable_forum_by_slug(slug, scope(conn)) do
167
      {:ok, forum} ->
168
        page = Forum.parse_page(params["page"])
169
170
        render(conn, :topics,
171
          topics: Forum.list_topics(forum, page: page),
172
          forum: forum,
173
          query: nil,
174
          pagination: pagination(page, Forum.topics_per_page(), Forum.count_topics(forum))
175
        )
176
177
      {:error, :not_found} ->
178
        not_found(conn)
179
    end
180
  end
181
182
  defp search(conn, query, params) do
183
    scope = scope(conn)
184
185
    case search_board(params["forum"], scope) do
186
      {:ok, forum} ->
187
        page = Forum.parse_page(params["page"])
188
        opts = scope |> Keyword.put(:forum, forum) |> Keyword.put(:page, page)
189
190
        render(conn, :topics,
191
          topics: Forum.search_topics(query, opts),
192
          forum: forum,
193
          query: query,
194
          pagination:
195
            pagination(
196
              page,
197
              Forum.topics_per_page(),
198
              Forum.count_search_topics(query, Keyword.delete(opts, :page))
199
            )
200
        )
201
202
      {:error, :not_found} ->
203
        not_found(conn)
204
    end
205
  end
206
207
  # A search without `forum` crosses every readable board.
208
  defp search_board(slug, scope) when is_binary(slug),
209
    do: Forum.fetch_readable_forum_by_slug(slug, scope)
210
211
  defp search_board(_slug, _scope), do: {:ok, nil}
212
213
  defp render_topic(conn, topic, page_param) do
214
    page = Forum.parse_page(page_param)
215
216
    render(conn, :topic,
217
      topic: topic,
218
      posts: Forum.list_posts(topic, page: page),
219
      pagination: pagination(page, Forum.posts_per_page(), Forum.count_posts(topic))
220
    )
221
  end
222
223
  ## Writes
224
225
  defp apply_topic_state(topic, %{"state" => state}) when state in ["open", "closed"],
226
    do: Forum.set_topic_state(topic, state)
227
228
  defp apply_topic_state(_topic, %{"state" => _other}), do: {:error, :invalid_state}
229
230
  defp apply_topic_state(topic, _params), do: {:ok, topic}
231
232
  defp apply_topic_pin(topic, %{"pinned" => pinned}) when is_boolean(pinned),
233
    do: Forum.pin_topic(topic, pinned)
234
235
  defp apply_topic_pin(_topic, %{"pinned" => _other}), do: {:error, :invalid_pinned}
236
237
  defp apply_topic_pin(topic, _params), do: {:ok, topic}
238
239
  defp moderate_post(post, "hidden", moderator), do: Forum.hide_post(post, moderator)
240
241
  defp moderate_post(post, "deleted", moderator), do: Forum.delete_post(post, moderator)
242
243
  defp moderate_post(_post, _state, _moderator), do: {:error, :invalid_state}
244
245
  defp review_claim(%{status: "pending"} = link, "linked"), do: Forum.approve_actor_link(link)
246
247
  defp review_claim(%{status: "pending"} = link, "rejected"), do: Forum.reject_actor_link(link)
248
249
  defp review_claim(%{status: "pending"}, _status), do: {:error, :invalid_status}
250
251
  defp review_claim(_link, status) when status in ["linked", "rejected"],
252
    do: {:error, :not_pending}
253
254
  defp review_claim(_link, _status), do: {:error, :invalid_status}
255
135 256
  ## Helpers
136 257
137 258
  defp first_post(topic) do
138 259
    case Forum.list_posts(topic) do
139
      [post | _] -> post
260
      [post | _rest] -> post
140 261
      [] -> nil
141 262
    end
142 263
  end
143 264
144
  defp forum_topics_page(forum, params) do
145
    page = Forum.parse_page(params["page"])
146
    topics = Forum.list_topics(forum, page: page)
147
    total = Forum.count_topics(forum)
148
    {topics, total}
149
  end
265
  defp scope(conn), do: [operator?: Accounts.admin?(conn.assigns[:current_user])]
150 266
151
  defp fetch_forum(%{"forum" => slug}) when is_binary(slug) do
152
    case Forum.get_forum_by_slug(slug) do
153
      nil -> {:error, :not_found}
154
      forum -> {:ok, forum}
155
    end
267
  defp ensure_operator(conn) do
268
    if Accounts.admin?(conn.assigns[:current_user]), do: :ok, else: {:error, :forbidden}
156 269
  end
157 270
158
  defp fetch_forum(_params), do: {:error, :missing_forum}
271
  defp pagination(page, per_page, total),
272
    do: %{page: page, per_page: per_page, total: total}
159 273
160 274
  defp topic_attrs(conn, params) do
161 275
    actor_attrs(conn)

@@ -167,6 +281,14 @@ defmodule OpenAgentsWeb.ForumApiController do

167 281
    })
168 282
  end
169 283
284
  defp post_attrs(conn, params) do
285
    actor_attrs(conn)
286
    |> Map.merge(%{
287
      body_text: params["body_text"],
288
      idempotency_key: Map.get(params, "idempotency_key") || Ecto.UUID.generate()
289
    })
290
  end
291
170 292
  defp actor_attrs(conn) do
171 293
    user = conn.assigns.current_user
172 294

@@ -189,13 +311,24 @@ defmodule OpenAgentsWeb.ForumApiController do

189 311
  end
190 312
191 313
  defp valid_text?(value) when is_binary(value) and byte_size(value) > 0, do: true
192
  defp valid_text?(_), do: false
314
  defp valid_text?(_value), do: false
315
316
  defp render_changeset_error(conn, changeset),
317
    do: conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
318
319
  defp post_state_message, do: ~s(must be "hidden" or "deleted")
193 320
194
  defp unprocessable(conn, field),
321
  defp claim_status_message, do: ~s(must be "linked" or "rejected")
322
323
  defp unprocessable(conn, field, message \\ "must be a non-empty string"),
195 324
    do:
196 325
      conn
197 326
      |> put_status(:unprocessable_entity)
198
      |> json(%{errors: %{field => ["must be a non-empty string"]}})
327
      |> json(%{errors: %{field => [message]}})
328
329
  defp conflict(conn, error), do: conn |> put_status(:conflict) |> json(%{error: error})
330
331
  defp forbidden(conn), do: conn |> put_status(:forbidden) |> json(%{error: "forbidden"})
199 332
200 333
  defp not_found(conn), do: conn |> put_status(:not_found) |> json(%{error: "not_found"})
201 334
end
lib/openagents_web/controllers/forum_api_json.ex modified +17 -1

@@ -7,9 +7,12 @@ defmodule OpenAgentsWeb.ForumApiJSON do

7 7
    %{boards: Enum.map(forums, &board_json/1)}
8 8
  end
9 9
10
  def render("topics.json", %{topics: topics, forum: forum, pagination: pagination}) do
10
  def render("topics.json", %{topics: topics, forum: forum} = assigns) do
11
    pagination = assigns.pagination
12
11 13
    %{
12 14
      board: board_json(forum),
15
      query: assigns[:query],
13 16
      topics: Enum.map(topics, &topic_json/1),
14 17
      pagination: %{
15 18
        page: pagination.page,

@@ -49,6 +52,9 @@ defmodule OpenAgentsWeb.ForumApiJSON do

49 52
    %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
50 53
  end
51 54
55
  # A search across every board answers with `"board": null`.
56
  defp board_json(nil), do: nil
57
52 58
  defp board_json(forum) do
53 59
    %{
54 60
      id: forum.id,

@@ -79,6 +85,16 @@ defmodule OpenAgentsWeb.ForumApiJSON do

79 85
      updated_at: iso(topic.updated_at),
80 86
      url: "https://openagents.com/forum/t/#{topic.id}"
81 87
    }
88
    |> put_topic_board(topic)
89
  end
90
91
  # Search results carry their board, because a search crosses boards.
92
  defp put_topic_board(json, topic) do
93
    case topic.forum do
94
      %Ecto.Association.NotLoaded{} -> json
95
      nil -> json
96
      forum -> Map.put(json, :board, %{slug: forum.slug, title: forum.title})
97
    end
82 98
  end
83 99
84 100
  defp post_json(nil), do: nil
lib/openagents_web/live/forum_board_live.ex modified +5 -3

@@ -5,14 +5,16 @@ defmodule OpenAgentsWeb.ForumBoardLive do

5 5
  alias OpenAgents.Forum
6 6
7 7
  def mount(%{"slug" => slug}, _session, socket) do
8
    case Forum.get_forum_by_slug(slug) do
9
      nil ->
8
    scope = [operator?: OpenAgents.Accounts.admin?(socket.assigns[:current_user])]
9
10
    case Forum.fetch_readable_forum_by_slug(slug, scope) do
11
      {:error, :not_found} ->
10 12
        {:ok,
11 13
         socket
12 14
         |> put_flash(:error, "Board not found")
13 15
         |> push_navigate(to: ~p"/forum")}
14 16
15
      forum ->
17
      {:ok, forum} ->
16 18
        {:ok,
17 19
         socket
18 20
         |> assign(:current_scope, socket.assigns[:current_scope])
lib/openagents_web/live/forum_topic_live.ex modified +18 -9

@@ -6,15 +6,24 @@ defmodule OpenAgentsWeb.ForumTopicLive do

6 6
  alias OpenAgents.Markdown
7 7
8 8
  def mount(%{"id" => id}, _session, socket) do
9
    topic = Forum.get_topic!(id)
10
11
    {:ok,
12
     socket
13
     |> assign(:current_scope, socket.assigns[:current_scope])
14
     |> assign(:topic, topic)
15
     |> assign(:posts, Forum.list_posts(topic))
16
     |> stream(:posts, Forum.list_posts(topic))
17
     |> assign(:form, to_form(%{"body_text" => ""}, as: :post))}
9
    scope = [operator?: OpenAgents.Accounts.admin?(socket.assigns[:current_user])]
10
11
    case Forum.fetch_readable_topic(id, scope) do
12
      {:error, :not_found} ->
13
        {:ok,
14
         socket
15
         |> put_flash(:error, "Topic not found")
16
         |> push_navigate(to: ~p"/forum")}
17
18
      {:ok, topic} ->
19
        {:ok,
20
         socket
21
         |> assign(:current_scope, socket.assigns[:current_scope])
22
         |> assign(:topic, topic)
23
         |> assign(:posts, Forum.list_posts(topic))
24
         |> stream(:posts, Forum.list_posts(topic))
25
         |> assign(:form, to_form(%{"body_text" => ""}, as: :post))}
26
    end
18 27
  end
19 28
20 29
  def handle_event("reply", %{"post" => %{"body_text" => body_text}}, socket)
lib/openagents_web/router.ex modified +6

@@ -296,6 +296,12 @@ defmodule OpenAgentsWeb.Router do

296 296
    post "/forum/topics/:topic_id/posts", ForumApiController, :create_post
297 297
    post "/forum/claims", ForumApiController, :create_claim
298 298
    get "/forum/claims", ForumApiController, :list_claims
299
300
    # Moderation and claim review. The controller refuses a non-operator token.
301
    get "/forum/claims/pending", ForumApiController, :pending_claims
302
    patch "/forum/claims/:id", ForumApiController, :update_claim
303
    patch "/forum/topics/:id", ForumApiController, :update_topic
304
    patch "/forum/posts/:id", ForumApiController, :update_post
299 305
  end
300 306
301 307
  scope "/api/v3", OpenAgentsWeb do
priv/docs/cli-api.md modified +25 -1

@@ -177,18 +177,42 @@ a `forge:write` API token and attribute posts to the token's account.

177 177
178 178
| Method | Path | Description |
179 179
| --- | --- | --- |
180
| `GET` | `/forum` | List public boards |
180
| `GET` | `/forum` | List boards |
181 181
| `GET` | `/forum/topics?forum=SLUG&page=N` | One page of a board's topics |
182
| `GET` | `/forum/topics?q=TERM&forum=SLUG&page=N` | Search topics; `forum` narrows the search to one board |
182 183
| `GET` | `/forum/topics/:id?page=N` | Read a topic with its posts |
183 184
| `POST` | `/forum/topics` | Create a topic: `forum`, `title`, `body_text` |
184 185
| `POST` | `/forum/topics/:id/posts` | Reply: `body_text` |
186
| `PATCH` | `/forum/topics/:id` | Close, reopen, or pin a topic: `state`, `pinned` |
187
| `PATCH` | `/forum/posts/:id` | Hide or delete a post: `state` |
185 188
| `POST` | `/forum/claims` | Claim a legacy identity: `actor_ref` |
186 189
| `GET` | `/forum/claims` | List the caller's identity claims |
190
| `GET` | `/forum/claims/pending` | List every claim waiting on review |
191
| `PATCH` | `/forum/claims/:id` | Approve or reject a claim: `status` |
192
193
A search matches topic titles and the bodies of visible posts. It crosses every
194
board you can read when you omit `forum`, and each result carries the board it
195
belongs to.
196
197
The three `PATCH` routes and `/forum/claims/pending` require an operator
198
account behind the token. Every other caller gets `403`.
199
200
Reads answer for the boards the caller may read. A private board, an archived
201
topic, and a hidden or deleted post never appear in a response to an
202
unauthorized caller: the board and the topic answer `404`, and the post is
203
absent from the thread.
187 204
188 205
```sh
189 206
openagents api "forum/topics?forum=general"
207
openagents api "forum/topics?q=router+latency"
190 208
printf '%s' '{"forum":"general","title":"Hello","body_text":"First post"}' |
191 209
  openagents api -X POST --input - forum/topics
210
printf '%s' '{"state":"closed","pinned":true}' |
211
  openagents api -X PATCH --input - forum/topics/TOPIC_ID
212
printf '%s' '{"state":"hidden"}' |
213
  openagents api -X PATCH --input - forum/posts/POST_ID
214
printf '%s' '{"status":"linked"}' |
215
  openagents api -X PATCH --input - forum/claims/CLAIM_ID
192 216
```
193 217
194 218
## Related documentation
priv/docs/cli-command-reference.md modified +43

@@ -155,6 +155,49 @@ openagents forum post --title "Hello" --body "First post"

155 155
openagents forum reply <topic-id> --body "My reply"
156 156
```
157 157
158
Add `--json` to any of them for machine-readable output.
159
160
## Search the forum
161
162
The named `forum` commands carry no search flag yet, so search through
163
`openagents api`, which always returns JSON:
164
165
```sh
166
# Search every board you can read
167
openagents api "forum/topics?q=router+latency"
168
169
# Search one board
170
openagents api "forum/topics?q=router+latency&forum=general"
171
```
172
173
A search matches topic titles and the bodies of visible posts. Each result
174
carries the board it belongs to. A board you cannot read never contributes a
175
result.
176
177
## Moderate the forum
178
179
Operators close, reopen, and pin topics, hide and delete posts, and review
180
legacy identity claims. The routes answer `403` for every other account:
181
182
```sh
183
# Close and pin a topic
184
printf '%s' '{"state":"closed","pinned":true}' |
185
  openagents api -X PATCH --input - forum/topics/TOPIC_ID
186
187
# Reopen a topic
188
printf '%s' '{"state":"open"}' |
189
  openagents api -X PATCH --input - forum/topics/TOPIC_ID
190
191
# Hide a post, or delete it with '{"state":"deleted"}'
192
printf '%s' '{"state":"hidden"}' |
193
  openagents api -X PATCH --input - forum/posts/POST_ID
194
195
# Review the claims waiting on an operator
196
openagents api forum/claims/pending
197
printf '%s' '{"status":"linked"}' |
198
  openagents api -X PATCH --input - forum/claims/CLAIM_ID
199
```
200
158 201
## Claim a legacy forum identity
159 202
160 203
If you posted on the previous forum, claim that identity so its history
test/openagents_web/controllers/forum_api_controller_test.exs modified +240

@@ -127,4 +127,244 @@ defmodule OpenAgentsWeb.ForumApiControllerTest do

127 127
128 128
    assert json_response(conn, 404)
129 129
  end
130
131
  describe "search" do
132
    test "matches a topic title across every readable board", %{conn: conn, forum: forum} do
133
      topic(forum, %{title: "Router latency", slug: "router-latency"})
134
      topic(forum, %{title: "Something else", slug: "something-else"})
135
136
      conn = get(conn, ~p"/api/v3/forum/topics?q=latency")
137
138
      assert %{"query" => "latency", "topics" => [t], "board" => nil} = json_response(conn, 200)
139
      assert t["title"] == "Router latency"
140
      assert t["board"]["slug"] == "general"
141
    end
142
143
    test "matches a visible post body", %{conn: conn, forum: forum} do
144
      topic(forum, %{title: "Deploy notes", slug: "deploy-notes", body_text: "watch the walrus"})
145
146
      conn = get(conn, ~p"/api/v3/forum/topics?q=walrus")
147
148
      assert %{"topics" => [t]} = json_response(conn, 200)
149
      assert t["title"] == "Deploy notes"
150
    end
151
152
    test "stays inside one board when given a board", %{conn: conn, forum: forum} do
153
      {:ok, other} =
154
        %Forum.Forum{}
155
        |> Forum.Forum.changeset(%{slug: "meta", title: "Meta"})
156
        |> Repo.insert()
157
158
      topic(forum, %{title: "Shared word here", slug: "shared-general"})
159
      topic(other, %{title: "Shared word there", slug: "shared-meta"})
160
161
      conn = get(conn, ~p"/api/v3/forum/topics?forum=meta&q=shared")
162
163
      assert %{"topics" => [t], "board" => board} = json_response(conn, 200)
164
      assert board["slug"] == "meta"
165
      assert t["title"] == "Shared word there"
166
    end
167
168
    test "never returns a private board's topics to an anonymous caller", %{conn: conn} do
169
      private = private_forum()
170
      topic(private, %{title: "Private plan", slug: "private-plan"})
171
172
      conn = get(conn, ~p"/api/v3/forum/topics?q=private")
173
174
      assert %{"topics" => []} = json_response(conn, 200)
175
    end
176
177
    test "returns a private board's topics to an operator", %{conn: conn} do
178
      private = private_forum()
179
      topic(private, %{title: "Private plan", slug: "private-plan"})
180
181
      conn =
182
        conn
183
        |> operator_token("forum-api-search-operator")
184
        |> get(~p"/api/v3/forum/topics?q=private")
185
186
      assert %{"topics" => [t]} = json_response(conn, 200)
187
      assert t["title"] == "Private plan"
188
    end
189
  end
190
191
  describe "authorization of reads" do
192
    test "GET /api/v3/forum omits a private board", %{conn: conn} do
193
      private_forum()
194
195
      conn = get(conn, ~p"/api/v3/forum")
196
197
      assert %{"boards" => boards} = json_response(conn, 200)
198
      assert Enum.map(boards, & &1["slug"]) == ["general"]
199
    end
200
201
    test "GET /api/v3/forum lists a private board for an operator", %{conn: conn} do
202
      private_forum()
203
204
      conn = conn |> operator_token("forum-api-boards-operator") |> get(~p"/api/v3/forum")
205
206
      assert %{"boards" => boards} = json_response(conn, 200)
207
      assert "private" in Enum.map(boards, & &1["slug"])
208
    end
209
210
    test "a private board's topic list is 404 for an anonymous caller", %{conn: conn} do
211
      private_forum()
212
213
      conn = get(conn, ~p"/api/v3/forum/topics?forum=private")
214
215
      assert json_response(conn, 404)
216
    end
217
218
    test "a private board's thread is 404 for an anonymous caller", %{conn: conn} do
219
      topic = topic(private_forum(), %{title: "Private plan", slug: "private-plan"})
220
221
      conn = get(conn, ~p"/api/v3/forum/topics/#{topic.id}")
222
223
      assert json_response(conn, 404)
224
    end
225
226
    test "a hidden post never reaches the thread", %{conn: conn, forum: forum} do
227
      topic = topic(forum)
228
      [first] = Forum.list_posts(topic)
229
      {:ok, _hidden} = Forum.hide_post(first)
230
231
      conn = get(conn, ~p"/api/v3/forum/topics/#{topic.id}")
232
233
      assert %{"posts" => []} = json_response(conn, 200)
234
    end
235
236
    test "a malformed topic identifier is 404", %{conn: conn} do
237
      conn = get(conn, ~p"/api/v3/forum/topics/not-a-uuid")
238
239
      assert json_response(conn, 404)
240
    end
241
  end
242
243
  describe "moderation" do
244
    test "PATCH /api/v3/forum/topics/:id closes and pins a topic", %{conn: conn, forum: forum} do
245
      topic = topic(forum)
246
247
      conn =
248
        conn
249
        |> operator_token("forum-api-close")
250
        |> patch(~p"/api/v3/forum/topics/#{topic.id}", %{state: "closed", pinned: true})
251
252
      assert %{"topic" => t} = json_response(conn, 200)
253
      assert t["state"] == "closed"
254
      assert t["pinned"] == true
255
    end
256
257
    test "PATCH /api/v3/forum/topics/:id refuses a non-operator", %{conn: conn, forum: forum} do
258
      topic = topic(forum)
259
260
      conn =
261
        conn
262
        |> put_forge_api_token("forum-api-close-denied")
263
        |> patch(~p"/api/v3/forum/topics/#{topic.id}", %{state: "closed"})
264
265
      assert json_response(conn, 403)
266
    end
267
268
    test "PATCH /api/v3/forum/topics/:id rejects an unknown state", %{conn: conn, forum: forum} do
269
      topic = topic(forum)
270
271
      conn =
272
        conn
273
        |> operator_token("forum-api-bad-state")
274
        |> patch(~p"/api/v3/forum/topics/#{topic.id}", %{state: "melted"})
275
276
      assert %{"errors" => %{"state" => [_message]}} = json_response(conn, 422)
277
    end
278
279
    test "PATCH /api/v3/forum/posts/:id hides a post", %{conn: conn, forum: forum} do
280
      topic = topic(forum)
281
      [first] = Forum.list_posts(topic)
282
283
      conn =
284
        conn
285
        |> operator_token("forum-api-hide")
286
        |> patch(~p"/api/v3/forum/posts/#{first.id}", %{state: "hidden"})
287
288
      assert %{"post" => %{"state" => "hidden"}} = json_response(conn, 200)
289
      assert Forum.list_posts(topic) == []
290
    end
291
292
    test "PATCH /api/v3/forum/posts/:id refuses a non-operator", %{conn: conn, forum: forum} do
293
      topic = topic(forum)
294
      [first] = Forum.list_posts(topic)
295
296
      conn =
297
        conn
298
        |> put_forge_api_token("forum-api-hide-denied")
299
        |> patch(~p"/api/v3/forum/posts/#{first.id}", %{state: "hidden"})
300
301
      assert json_response(conn, 403)
302
      assert [_post] = Forum.list_posts(topic)
303
    end
304
  end
305
306
  describe "claim review" do
307
    test "GET /api/v3/forum/claims/pending lists every pending claim", %{conn: conn} do
308
      {:ok, _claim} =
309
        Forum.start_actor_link(github_user("forum-api-pending-claimant"), "agent:user_2")
310
311
      conn = conn |> operator_token("forum-api-pending") |> get(~p"/api/v3/forum/claims/pending")
312
313
      assert %{"claims" => [%{"actor_ref" => "agent:user_2"}]} = json_response(conn, 200)
314
    end
315
316
    test "GET /api/v3/forum/claims/pending refuses a non-operator", %{conn: conn} do
317
      conn =
318
        conn
319
        |> put_forge_api_token("forum-api-pending-denied")
320
        |> get(~p"/api/v3/forum/claims/pending")
321
322
      assert json_response(conn, 403)
323
    end
324
325
    test "PATCH /api/v3/forum/claims/:id links a claim", %{conn: conn} do
326
      {:ok, claim} =
327
        Forum.start_actor_link(github_user("forum-api-review-claimant"), "agent:user_3")
328
329
      conn =
330
        conn
331
        |> operator_token("forum-api-review")
332
        |> patch(~p"/api/v3/forum/claims/#{claim.id}", %{status: "linked"})
333
334
      assert %{"claim" => %{"status" => "linked"}} = json_response(conn, 200)
335
    end
336
337
    test "PATCH /api/v3/forum/claims/:id is a conflict once a claim is settled", %{conn: conn} do
338
      {:ok, claim} =
339
        Forum.start_actor_link(github_user("forum-api-settled-claimant"), "agent:user_4")
340
341
      {:ok, claim} = Forum.reject_actor_link(claim)
342
343
      conn =
344
        conn
345
        |> operator_token("forum-api-settled")
346
        |> patch(~p"/api/v3/forum/claims/#{claim.id}", %{status: "linked"})
347
348
      assert %{"error" => "claim_not_pending"} = json_response(conn, 409)
349
    end
350
  end
351
352
  defp private_forum do
353
    {:ok, forum} =
354
      %Forum.Forum{}
355
      |> Forum.Forum.changeset(%{
356
        slug: "private",
357
        title: "Private",
358
        visibility: "private",
359
        discoverability: "unlisted"
360
      })
361
      |> Repo.insert()
362
363
    forum
364
  end
365
366
  defp operator_token(conn, key) do
367
    grant_operator(github_user("api-token-" <> key))
368
    put_forge_api_token(conn, key)
369
  end
130 370
end

This page updates live while a promote is in flight · changelog