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