From 6a71856b3de415027b48e9fcfda786ffa3cf7e95 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 8 Apr 2026 08:39:36 -0400 Subject: [PATCH] Add JSON activities API endpoint (#2783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- app/controllers/activities_controller.rb | 30 ++ app/models/event.rb | 5 +- app/models/event/particulars.rb | 17 ++ app/views/activities/_activity.json.jbuilder | 21 ++ app/views/activities/index.json.jbuilder | 1 + config/routes.rb | 1 + docs/api/README.md | 1 + docs/api/sections/activities.md | 161 +++++++++++ .../controllers/activities_controller_test.rb | 272 ++++++++++++++++++ test/models/event_test.rb | 53 ++++ 10 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 app/controllers/activities_controller.rb create mode 100644 app/views/activities/_activity.json.jbuilder create mode 100644 app/views/activities/index.json.jbuilder create mode 100644 docs/api/sections/activities.md create mode 100644 test/controllers/activities_controller_test.rb create mode 100644 test/models/event_test.rb diff --git a/app/controllers/activities_controller.rb b/app/controllers/activities_controller.rb new file mode 100644 index 000000000..51ae3de8f --- /dev/null +++ b/app/controllers/activities_controller.rb @@ -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 diff --git a/app/models/event.rb b/app/models/event.rb index bbbf2a60c..23495326d 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -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 ] } diff --git a/app/models/event/particulars.rb b/app/models/event/particulars.rb index 31ecd66bf..d7a133ae2 100644 --- a/app/models/event/particulars.rb +++ b/app/models/event/particulars.rb @@ -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 diff --git a/app/views/activities/_activity.json.jbuilder b/app/views/activities/_activity.json.jbuilder new file mode 100644 index 000000000..5585ac1fe --- /dev/null +++ b/app/views/activities/_activity.json.jbuilder @@ -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 diff --git a/app/views/activities/index.json.jbuilder b/app/views/activities/index.json.jbuilder new file mode 100644 index 000000000..1e4048184 --- /dev/null +++ b/app/views/activities/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "activities/activity", as: :event diff --git a/config/routes.rb b/config/routes.rb index d3eb300d0..6c01c8d02 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -133,6 +133,7 @@ Rails.application.routes.draw do end end + resources :activities, only: :index resources :events, only: :index namespace :events do resources :days diff --git a/docs/api/README.md b/docs/api/README.md index 6c9e8b8d5..065d09e63 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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) diff --git a/docs/api/sections/activities.md b/docs/api/sections/activities.md new file mode 100644 index 000000000..3820bdd28 --- /dev/null +++ b/docs/api/sections/activities.md @@ -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": "
Users cannot complete login on iOS.
", + "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": "
I found the regression in the callback flow.
" + }, + "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. diff --git a/test/controllers/activities_controller_test.rb b/test/controllers/activities_controller_test.rb new file mode 100644 index 000000000..82357e3d9 --- /dev/null +++ b/test/controllers/activities_controller_test.rb @@ -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 diff --git a/test/models/event_test.rb b/test/models/event_test.rb new file mode 100644 index 000000000..3c50bd2a1 --- /dev/null +++ b/test/models/event_test.rb @@ -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