Add JSON activities API endpoint (#2783)

* Add JSON events API endpoint

* Add regression test for event particulars defaults

* Move JSON events API to a dedicated ActivitiesController

The events endpoint served both the HTML day timeline and the JSON API
feed, but the two paths shared no data or behavior — the HTML side uses
DayTimelinesScoped while the JSON side built its own query. Splitting
into ActivitiesController gives the API its own home at
GET /:account/activities.json without dragging in the timeline
before_actions.

Also preloads comment creator in Event.preloaded to avoid an N+1 when
rendering comment eventables in the JSON feed.
This commit is contained in:
Rob Zolkos
2026-04-08 08:39:36 -04:00
committed by GitHub
parent 2a4adf3a2b
commit 6a71856b3d
10 changed files with 561 additions and 1 deletions
+30
View File
@@ -0,0 +1,30 @@
class ActivitiesController < ApplicationController
ACTIONS = %w[
card_assigned
card_auto_postponed
card_board_changed
card_closed
card_postponed
card_published
card_reopened
card_sent_back_to_triage
card_title_changed
card_triaged
card_unassigned
comment_created
].freeze
def index
set_page_and_extract_portion_from(activities)
end
private
def activities
Current.user.accessible_events
.preloaded
.where(action: ACTIONS)
.for_creators(params[:creator_ids])
.for_boards(params[:board_ids])
.reverse_chronologically
end
end
+4 -1
View File
@@ -9,10 +9,13 @@ class Event < ApplicationRecord
has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all
scope :chronologically, -> { order created_at: :asc, id: :desc }
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
scope :for_creators, ->(ids) { where(creator_id: ids) if ids.present? }
scope :for_boards, ->(ids) { where(board_id: ids) if ids.present? }
scope :preloaded, -> {
includes(:creator, :board, {
eventable: [
:goldness, :closure, :image_attachment,
:creator, :goldness, :closure, :image_attachment,
{ rich_text_body: :embeds_attachments },
{ rich_text_description: :embeds_attachments },
{ card: [ :goldness, :closure, :image_attachment ] }
+17
View File
@@ -8,4 +8,21 @@ module Event::Particulars
def assignees
@assignees ||= User.where id: assignee_ids
end
def api_particulars
nested = particulars.dig("particulars") || {}
case action.to_s
when "card_assigned", "card_unassigned"
{ "assignee_ids" => Array(assignee_ids) }
when "card_board_changed"
{ "old_board" => nested["old_board"].to_s, "new_board" => nested["new_board"].to_s }
when "card_title_changed"
{ "old_title" => nested["old_title"].to_s, "new_title" => nested["new_title"].to_s }
when "card_triaged"
{ "column" => nested["column"].to_s }
else
{}
end
end
end
@@ -0,0 +1,21 @@
json.(event, :id, :action)
json.created_at event.created_at.utc
json.description event.description_for(Current.user).to_plain_text
json.particulars event.api_particulars
json.url(
case event.eventable
when Comment then card_url(event.eventable.card, anchor: dom_id(event.eventable))
else polymorphic_url(event.eventable)
end
)
json.eventable_type event.eventable_type
json.eventable do
case event.eventable
when Card then json.partial! "cards/card", card: event.eventable
when Comment then json.partial! "cards/comments/comment", comment: event.eventable
end
end
json.board event.board, partial: "boards/board", as: :board
json.creator event.creator, partial: "users/user", as: :user
+1
View File
@@ -0,0 +1 @@
json.array! @page.records, partial: "activities/activity", as: :event
+1
View File
@@ -133,6 +133,7 @@ Rails.application.routes.draw do
end
end
resources :activities, only: :index
resources :events, only: :index
namespace :events do
resources :days
+1
View File
@@ -17,6 +17,7 @@ a bot to perform various actions for you.
- [Reactions](sections/reactions.md)
- [Tags](sections/tags.md)
- [Users](sections/users.md)
- [Activities](sections/activities.md)
- [Notifications](sections/notifications.md)
- [Rich Text](sections/rich_text.md)
- [Exports](sections/exports.md)
+161
View File
@@ -0,0 +1,161 @@
# Activities
Activities are the activity stream for an account — a record of significant actions like cards being published, assigned, closed, and commented on.
## `GET /:account_slug/activities`
Returns a paginated flat list of activities the current user can access, sorted newest first.
__Query Parameters:__
| Parameter | Description |
|-----------|-------------|
| `creator_ids[]` | Filter to activities created by specific user ID(s). Multiple values are ORed. |
| `board_ids[]` | Filter to activities on specific board ID(s). Multiple values are ORed. |
Different filter params are ANDed together: `creator_ids[]=A&board_ids[]=X` means activities created by A on board X.
To fetch activity for a specific user, use `creator_ids[]=USER_ID`.
__Supported actions:__
| `action` | `eventable_type` | `particulars` shape |
|----------|-----------------|---------------------|
| `card_assigned` | `Card` | `{ "assignee_ids": [USER_ID, ...] }` |
| `card_auto_postponed` | `Card` | `{}` |
| `card_board_changed` | `Card` | `{ "old_board": STRING, "new_board": STRING }` |
| `card_closed` | `Card` | `{}` |
| `card_postponed` | `Card` | `{}` |
| `card_published` | `Card` | `{}` |
| `card_reopened` | `Card` | `{}` |
| `card_sent_back_to_triage` | `Card` | `{}` |
| `card_title_changed` | `Card` | `{ "old_title": STRING, "new_title": STRING }` |
| `card_triaged` | `Card` | `{ "column": STRING }` |
| `card_unassigned` | `Card` | `{ "assignee_ids": [USER_ID, ...] }` |
| `comment_created` | `Comment` | `{}` |
`particulars` is always an object. It contains action-specific metadata in a normalized format intended for API clients. It does not necessarily mirror the internal event JSON stored by Fizzy. Unknown keys may appear in the future and should be ignored. For `card_assigned` and `card_unassigned`, `assignee_ids` is currently a single-element array in practice.
__`particulars` examples:__
```json
{ "action": "card_assigned", "particulars": { "assignee_ids": ["03f5user123"] } }
{ "action": "card_unassigned", "particulars": { "assignee_ids": ["03f5user123"] } }
{ "action": "card_board_changed", "particulars": { "old_board": "Backlog", "new_board": "Mobile" } }
{ "action": "card_title_changed", "particulars": { "old_title": "Fix login", "new_title": "Fix mobile login" } }
{ "action": "card_triaged", "particulars": { "column": "In Progress" } }
{ "action": "card_closed", "particulars": {} }
```
The practical `eventable_type` values are `Card` and `Comment`. Clients should handle unknown future values conservatively.
Activities whose underlying `Card` or `Comment` has been deleted or is inaccessible to the current user are omitted from the feed. The endpoint never returns `eventable: null`.
The top-level `board` field reflects the activity's current board association. If a card moves boards, all its activities move with it for the purposes of this feed and `board_ids[]` filtering.
This endpoint is a paginated activity feed, not an immutable audit-log. `description`, `eventable`, `board`, and `creator` may reflect current resource state. Re-fetch recent pages to get fresh activity data.
__Response:__
```json
[
{
"id": "03faevt004",
"action": "card_closed",
"created_at": "2026-03-25T15:11:04.000Z",
"description": "David Heinemeier Hansson moved \"Fix mobile login\" to \"Done\"",
"particulars": {},
"url": "http://fizzy.localhost:3006/897362094/cards/42",
"eventable_type": "Card",
"eventable": {
"id": "03f6card042",
"number": 42,
"title": "Fix mobile login",
"status": "closed",
"description": "Users cannot complete login on iOS.",
"description_html": "<div>Users cannot complete login on iOS.</div>",
"image_url": null,
"has_attachments": false,
"tags": ["ios", "auth"],
"closed": true,
"postponed": false,
"golden": false,
"last_active_at": "2026-03-25T15:11:04.000Z",
"created_at": "2026-03-25T09:00:00.000Z",
"url": "http://fizzy.localhost:3006/897362094/cards/42"
},
"board": {
"id": "03f6abc123",
"name": "Mobile",
"all_access": true,
"created_at": "2026-03-01T10:00:00.000Z",
"auto_postpone_period_in_days": 14,
"url": "http://fizzy.localhost:3006/897362094/boards/03f6abc123"
},
"creator": {
"id": "03f5user123",
"name": "David Heinemeier Hansson",
"role": "owner",
"active": true,
"email_address": "david@example.com",
"created_at": "2026-03-01T09:00:00.000Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5user123",
"avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5user123/avatar"
}
},
{
"id": "03faevt003",
"action": "comment_created",
"created_at": "2026-03-25T14:17:22.000Z",
"description": "David Heinemeier Hansson commented on \"Fix mobile login\"",
"particulars": {},
"url": "http://fizzy.localhost:3006/897362094/cards/42#comment_03facomment9",
"eventable_type": "Comment",
"eventable": {
"id": "03facomment9",
"created_at": "2026-03-25T14:17:22.000Z",
"updated_at": "2026-03-25T14:17:22.000Z",
"body": {
"plain_text": "I found the regression in the callback flow.",
"html": "<div>I found the regression in the callback flow.</div>"
},
"creator": {
"id": "03f5user123",
"name": "David Heinemeier Hansson",
"role": "owner",
"active": true,
"email_address": "david@example.com",
"created_at": "2026-03-01T09:00:00.000Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5user123",
"avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5user123/avatar"
},
"card": {
"id": "03f6card042",
"url": "http://fizzy.localhost:3006/897362094/cards/42"
},
"reactions_url": "http://fizzy.localhost:3006/897362094/cards/42/comments/03facomment9/reactions",
"url": "http://fizzy.localhost:3006/897362094/cards/42/comments/03facomment9"
},
"board": {
"id": "03f6abc123",
"name": "Mobile",
"all_access": true,
"created_at": "2026-03-01T10:00:00.000Z",
"auto_postpone_period_in_days": 14,
"url": "http://fizzy.localhost:3006/897362094/boards/03f6abc123"
},
"creator": {
"id": "03f5user123",
"name": "David Heinemeier Hansson",
"role": "owner",
"active": true,
"email_address": "david@example.com",
"created_at": "2026-03-01T09:00:00.000Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5user123",
"avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5user123/avatar"
}
}
]
```
All `url` fields are opaque absolute URLs for the current Fizzy instance. Clients should not construct them.
@@ -0,0 +1,272 @@
require "test_helper"
class ActivitiesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "index returns flat array of events with required envelope fields" do
get activities_path, as: :json
assert_response :success
body = @response.parsed_body
assert_kind_of Array, body
assert body.any?
event = body.first
assert_includes event.keys, "id"
assert_includes event.keys, "action"
assert_includes event.keys, "created_at"
assert_includes event.keys, "description"
assert_includes event.keys, "particulars"
assert_includes event.keys, "url"
assert_includes event.keys, "eventable_type"
assert_includes event.keys, "eventable"
assert_includes event.keys, "board"
assert_includes event.keys, "creator"
end
test "index returns events in reverse-chronological order" do
get activities_path, as: :json
assert_response :success
times = @response.parsed_body.map { |e| Time.parse(e["created_at"]) }
assert_equal times, times.sort.reverse
end
test "index includes card eventable" do
event = events(:logo_published)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "Card", item["eventable_type"]
assert_equal cards(:logo).title, item["eventable"]["title"]
assert_equal "card_published", item["action"]
assert item["url"].end_with?("/cards/#{cards(:logo).number}")
end
test "index includes comment eventable" do
event = events(:layout_commented)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "Comment", item["eventable_type"]
assert_equal card_url(event.eventable.card, anchor: ActionView::RecordIdentifier.dom_id(event.eventable)), item["url"]
assert item["eventable"]["body"].present?
end
test "index includes board and creator at top level" do
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.first
assert item["board"]["id"].present?
assert item["board"]["name"].present?
assert item["creator"]["id"].present?
assert item["creator"]["name"].present?
end
test "index normalizes particulars for card_assigned" do
event = events(:logo_assignment_jz)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "card_assigned", item["action"]
assert_equal [ users(:jz).id ], item["particulars"]["assignee_ids"]
end
test "index normalizes particulars for card_unassigned" do
card = cards(:logo)
event = card.board.events.create!(
action: "card_unassigned",
creator: users(:david),
eventable: card,
account: accounts("37s"),
particulars: { assignee_ids: [ users(:jz).id ] }
)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal [ users(:jz).id ], item["particulars"]["assignee_ids"]
end
test "index returns empty particulars for actions with no payload" do
event = events(:logo_published)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal({}, item["particulars"])
end
test "index normalizes particulars for card_board_changed" do
card = cards(:logo)
event = card.board.events.create!(
action: "card_board_changed",
creator: users(:david),
eventable: card,
account: accounts("37s"),
particulars: { particulars: { old_board: "Backlog", new_board: "Mobile" } }
)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "Backlog", item["particulars"]["old_board"]
assert_equal "Mobile", item["particulars"]["new_board"]
end
test "index normalizes particulars for card_title_changed" do
card = cards(:logo)
event = card.board.events.create!(
action: "card_title_changed",
creator: users(:david),
eventable: card,
account: accounts("37s"),
particulars: { particulars: { old_title: "Old title", new_title: "New title" } }
)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "Old title", item["particulars"]["old_title"]
assert_equal "New title", item["particulars"]["new_title"]
end
test "index normalizes particulars for card_triaged" do
card = cards(:logo)
event = card.board.events.create!(
action: "card_triaged",
creator: users(:david),
eventable: card,
account: accounts("37s"),
particulars: { particulars: { column: "In Progress" } }
)
get activities_path, as: :json
assert_response :success
item = @response.parsed_body.find { |e| e["id"] == event.id }
assert_not_nil item
assert_equal "In Progress", item["particulars"]["column"]
end
test "index filters by creator_ids" do
get activities_path(creator_ids: [ users(:kevin).id ]), as: :json
assert_response :success
body = @response.parsed_body
assert body.any?
assert body.all? { |e| e["creator"]["id"] == users(:kevin).id }
end
test "index filters by multiple creator_ids with OR semantics" do
get activities_path(creator_ids: [ users(:david).id, users(:kevin).id ]), as: :json
assert_response :success
body = @response.parsed_body
creator_ids = body.map { |e| e["creator"]["id"] }.uniq.sort
assert_includes creator_ids, users(:david).id
assert_includes creator_ids, users(:kevin).id
end
test "index filters by board_ids" do
get activities_path(board_ids: [ boards(:writebook).id ]), as: :json
assert_response :success
body = @response.parsed_body
assert body.any?
assert body.all? { |e| e["board"]["id"] == boards(:writebook).id }
end
test "index ANDs creator_ids and board_ids filters" do
get activities_path(creator_ids: [ users(:david).id ], board_ids: [ boards(:writebook).id ]), as: :json
assert_response :success
body = @response.parsed_body
assert body.any?
assert body.all? { |e| e["creator"]["id"] == users(:david).id }
assert body.all? { |e| e["board"]["id"] == boards(:writebook).id }
end
test "index ignores empty creator_ids and board_ids filters" do
get activities_path(creator_ids: [], board_ids: []), as: :json
assert_response :success
assert_predicate @response.parsed_body, :any?
end
test "index only returns events from accessible boards" do
get activities_path, as: :json
assert_response :success
accessible_board_ids = users(:kevin).boards.pluck(:id)
@response.parsed_body.each do |item|
assert_includes accessible_board_ids, item["board"]["id"]
end
end
test "index paginates and returns Link header when more results exist" do
board = boards(:writebook)
30.times do |i|
board.events.create!(
action: "card_published",
creator: users(:kevin),
eventable: cards(:logo),
account: accounts("37s")
)
end
get activities_path, as: :json
assert_response :success
link_header = @response.headers["Link"]
assert link_header.present?, "Expected a Link header for paginated results"
assert_match(/rel="next"/, link_header)
end
test "index follows pagination to retrieve all events" do
board = boards(:writebook)
30.times do
board.events.create!(
action: "card_published",
creator: users(:kevin),
eventable: cards(:logo),
account: accounts("37s")
)
end
all_ids = []
next_path = activities_path(format: :json)
while next_path
get next_path
assert_response :success
all_ids.concat(@response.parsed_body.map { |e| e["id"] })
next_path = next_page_from_link_header(@response.headers["Link"])
end
total = Event.where(board: users(:kevin).boards).where(action: ActivitiesController::ACTIONS).count
assert_equal total, all_ids.uniq.count
end
private
def next_page_from_link_header(link_header)
url = link_header&.match(/<([^>]+)>;\s*rel="next"/)&.captures&.first
URI.parse(url).request_uri if url
end
end
+53
View File
@@ -0,0 +1,53 @@
require "test_helper"
class EventTest < ActiveSupport::TestCase
test "blank creator and board filters return the current relation unchanged" do
relation = Event.where(action: "card_published")
assert_equal relation.to_sql, relation.for_creators(nil).to_sql
assert_equal relation.to_sql, relation.for_creators([]).to_sql
assert_equal relation.to_sql, relation.for_boards(nil).to_sql
assert_equal relation.to_sql, relation.for_boards([]).to_sql
end
test "blank creator and board filters remain chainable" do
relation = Event.where(action: "card_published")
assert_nothing_raised do
relation.for_creators([]).for_boards([]).load
end
end
test "api_particulars returns empty strings for missing nested board change values" do
event = boards(:writebook).events.create!(
action: "card_board_changed",
creator: users(:david),
eventable: cards(:logo),
account: accounts("37s"),
particulars: {}
)
assert_equal({ "old_board" => "", "new_board" => "" }, event.api_particulars)
end
test "api_particulars returns empty strings for missing nested title and column values" do
title_event = boards(:writebook).events.create!(
action: "card_title_changed",
creator: users(:david),
eventable: cards(:logo),
account: accounts("37s"),
particulars: {}
)
triage_event = boards(:writebook).events.create!(
action: "card_triaged",
creator: users(:david),
eventable: cards(:logo),
account: accounts("37s"),
particulars: {}
)
assert_equal({ "old_title" => "", "new_title" => "" }, title_event.api_particulars)
assert_equal({ "column" => "" }, triage_event.api_particulars)
end
end