From 1d8b765bab4f14b40a599cc639c06669c59094a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 14 Jan 2026 17:10:54 +0100 Subject: [PATCH 01/26] Add JSON support for pin/unpin on the card endpoints and add tests. --- app/controllers/cards/pins_controller.rb | 12 ++++++++-- .../controllers/cards/pins_controller_test.rb | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/controllers/cards/pins_controller.rb b/app/controllers/cards/pins_controller.rb index f8da07da3..d46c6a48d 100644 --- a/app/controllers/cards/pins_controller.rb +++ b/app/controllers/cards/pins_controller.rb @@ -9,14 +9,22 @@ class Cards::PinsController < ApplicationController @pin = @card.pin_by Current.user broadcast_add_pin_to_tray - render_pin_button_replacement + + respond_to do |format| + format.turbo_stream { render_pin_button_replacement } + format.json { head :no_content } + end end def destroy @pin = @card.unpin_by Current.user broadcast_remove_pin_from_tray - render_pin_button_replacement + + respond_to do |format| + format.turbo_stream { render_pin_button_replacement } + format.json { head :no_content } + end end private diff --git a/test/controllers/cards/pins_controller_test.rb b/test/controllers/cards/pins_controller_test.rb index 3bc569827..db26a12e7 100644 --- a/test/controllers/cards/pins_controller_test.rb +++ b/test/controllers/cards/pins_controller_test.rb @@ -17,6 +17,17 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "create as JSON" do + card = cards(:layout) + + assert_not card.pinned_by?(users(:kevin)) + + post card_pin_path(card), as: :json + + assert_response :no_content + assert card.reload.pinned_by?(users(:kevin)) + end + test "destroy" do assert_changes -> { cards(:shipping).pinned_by?(users(:kevin)) }, from: true, to: false do perform_enqueued_jobs do @@ -28,4 +39,15 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + + test "destroy as JSON" do + card = cards(:shipping) + + assert card.pinned_by?(users(:kevin)) + + delete card_pin_path(card), as: :json + + assert_response :no_content + assert_not card.reload.pinned_by?(users(:kevin)) + end end From e82dc08f6cfe7e1a114489283139fb06ae124157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Wed, 14 Jan 2026 17:43:16 +0100 Subject: [PATCH 02/26] Add JSON support for GET `/my/pins` with no pagination and tests. --- app/controllers/my/pins_controller.rb | 11 ++++++++++- app/views/my/pins/index.json.jbuilder | 3 +++ test/controllers/my/pins_controller_test.rb | 10 ++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 app/views/my/pins/index.json.jbuilder diff --git a/app/controllers/my/pins_controller.rb b/app/controllers/my/pins_controller.rb index 4468c75d5..93b77df79 100644 --- a/app/controllers/my/pins_controller.rb +++ b/app/controllers/my/pins_controller.rb @@ -1,6 +1,15 @@ class My::PinsController < ApplicationController def index - @pins = Current.user.pins.includes(:card).ordered.limit(20) + @pins = pins_for_request fresh_when etag: [ @pins, @pins.collect(&:card) ] end + + private + def pins_for_request + if request.format.json? + Current.user.pins.includes(:card).ordered + else + Current.user.pins.includes(:card).ordered.limit(20) + end + end end diff --git a/app/views/my/pins/index.json.jbuilder b/app/views/my/pins/index.json.jbuilder new file mode 100644 index 000000000..f3a39ae84 --- /dev/null +++ b/app/views/my/pins/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @pins do |pin| + json.partial! "cards/card", card: pin.card +end diff --git a/test/controllers/my/pins_controller_test.rb b/test/controllers/my/pins_controller_test.rb index 3c8ca33e2..daff96354 100644 --- a/test/controllers/my/pins_controller_test.rb +++ b/test/controllers/my/pins_controller_test.rb @@ -11,4 +11,14 @@ class My::PinsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select "div", text: /#{users(:kevin).pins.first.card.title}/ end + + test "index as JSON" do + expected_ids = users(:kevin).pins.ordered.pluck(:card_id) + + get my_pins_path(format: :json) + + assert_response :success + assert_equal expected_ids.count, @response.parsed_body.count + assert_equal expected_ids, @response.parsed_body.map { |card| card["id"] } + end end From 90a7b15db3116a3072e35d6686b17054ebcf7671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Tue, 20 Jan 2026 12:32:45 +0100 Subject: [PATCH 03/26] Move pins scope into a variable. Keep web limit to 20 and set JSON to 100. --- app/controllers/my/pins_controller.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/controllers/my/pins_controller.rb b/app/controllers/my/pins_controller.rb index 93b77df79..70e40ce98 100644 --- a/app/controllers/my/pins_controller.rb +++ b/app/controllers/my/pins_controller.rb @@ -6,10 +6,12 @@ class My::PinsController < ApplicationController private def pins_for_request + pins = Current.user.pins.includes(:card).ordered + if request.format.json? - Current.user.pins.includes(:card).ordered + pins.limit(100) else - Current.user.pins.includes(:card).ordered.limit(20) + pins.limit(20) end end end From 5384116f11862a017ebe1083d0597c6a7d83ebbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 22 Jan 2026 16:44:30 +0100 Subject: [PATCH 04/26] Rename the helper to user_pins and extract a pins_limit method. --- app/controllers/my/pins_controller.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/controllers/my/pins_controller.rb b/app/controllers/my/pins_controller.rb index 70e40ce98..18b58910f 100644 --- a/app/controllers/my/pins_controller.rb +++ b/app/controllers/my/pins_controller.rb @@ -1,17 +1,15 @@ class My::PinsController < ApplicationController def index - @pins = pins_for_request + @pins = user_pins fresh_when etag: [ @pins, @pins.collect(&:card) ] end private - def pins_for_request - pins = Current.user.pins.includes(:card).ordered + def user_pins + Current.user.pins.includes(:card).ordered.limit(pins_limit) + end - if request.format.json? - pins.limit(100) - else - pins.limit(20) - end + def pins_limit + request.format.json? ? 100 : 20 end end From 1570c16f67c3f93bd223945e4b85fbb1694b533a Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 21 Jan 2026 11:26:03 -0500 Subject: [PATCH 05/26] Make Reaction polymorphic Add migration to replace comment_id with polymorphic reactable association (reactable_type, reactable_id), enabling reactions on both comments and cards. Co-Authored-By: Claude Opus 4.5 --- ...260121155752_make_reactions_polymorphic.rb | 27 +++++++++++++++++++ db/schema.rb | 7 ++--- db/schema_sqlite.rb | 7 ++--- 3 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 db/migrate/20260121155752_make_reactions_polymorphic.rb diff --git a/db/migrate/20260121155752_make_reactions_polymorphic.rb b/db/migrate/20260121155752_make_reactions_polymorphic.rb new file mode 100644 index 000000000..6c3d9cac4 --- /dev/null +++ b/db/migrate/20260121155752_make_reactions_polymorphic.rb @@ -0,0 +1,27 @@ +class MakeReactionsPolymorphic < ActiveRecord::Migration[8.0] + def change + add_column :reactions, :reactable_type, :string + add_column :reactions, :reactable_id, :uuid + + reversible do |dir| + dir.up do + execute <<~SQL + UPDATE reactions SET reactable_type = 'Comment', reactable_id = comment_id + SQL + end + + dir.down do + execute <<~SQL + UPDATE reactions SET comment_id = reactable_id WHERE reactable_type = 'Comment' + SQL + end + end + + change_column_null :reactions, :reactable_type, false + change_column_null :reactions, :reactable_id, false + + remove_column :reactions, :comment_id, :uuid + + add_index :reactions, [:reactable_type, :reactable_id] + end +end diff --git a/db/schema.rb b/db/schema.rb index 86b9f2937..fc83e3f58 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -412,13 +412,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do create_table "reactions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "comment_id", null: false t.string "content", limit: 16, null: false t.datetime "created_at", null: false + t.uuid "reactable_id", null: false + t.string "reactable_type", null: false t.uuid "reacter_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_reactions_on_account_id" - t.index ["comment_id"], name: "index_reactions_on_comment_id" + t.index ["reactable_type", "reactable_id"], name: "index_reactions_on_reactable_type_and_reactable_id" t.index ["reacter_id"], name: "index_reactions_on_reacter_id" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index b76f99865..817e37204 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -412,13 +412,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do create_table "reactions", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false - t.uuid "comment_id", null: false t.string "content", limit: 16, null: false t.datetime "created_at", null: false + t.uuid "reactable_id", null: false + t.string "reactable_type", limit: 255, null: false t.uuid "reacter_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_reactions_on_account_id" - t.index ["comment_id"], name: "index_reactions_on_comment_id" + t.index ["reactable_type", "reactable_id"], name: "index_reactions_on_reactable_type_and_reactable_id" t.index ["reacter_id"], name: "index_reactions_on_reacter_id" end From ffab4cad6f29d136734afdd94fd7995055012975 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 21 Jan 2026 12:05:32 -0500 Subject: [PATCH 06/26] Update models, views, and fixtures for polymorphic reactions - Reaction: belongs_to :reactable (polymorphic) instead of :comment - Comment: has_many :reactions with as: :reactable - Views: use reaction.reactable instead of reaction.comment - Fixtures: use polymorphic syntax for reactable association Co-Authored-By: Claude Opus 4.5 --- app/models/comment.rb | 2 +- app/models/reaction.rb | 6 +++--- app/views/cards/comments/reactions/_reaction.html.erb | 2 +- app/views/cards/comments/reactions/_reaction.json.jbuilder | 2 +- test/fixtures/reactions.yml | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/models/comment.rb b/app/models/comment.rb index 30706e8b5..41e068514 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -4,7 +4,7 @@ class Comment < ApplicationRecord belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } - has_many :reactions, -> { order(:created_at) }, dependent: :delete_all + has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all has_rich_text :body diff --git a/app/models/reaction.rb b/app/models/reaction.rb index 76edf152e..be5fc5d7e 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -1,6 +1,6 @@ class Reaction < ApplicationRecord - belongs_to :account, default: -> { comment.account } - belongs_to :comment, touch: true + belongs_to :account, default: -> { reactable.account } + belongs_to :reactable, polymorphic: true, touch: true belongs_to :reacter, class_name: "User", default: -> { Current.user } scope :ordered, -> { order(:created_at) } @@ -11,6 +11,6 @@ class Reaction < ApplicationRecord private def register_card_activity - comment.card.touch_last_active_at + reactable.card.touch_last_active_at end end diff --git a/app/views/cards/comments/reactions/_reaction.html.erb b/app/views/cards/comments/reactions/_reaction.html.erb index 2c9dfc01b..da4625880 100644 --- a/app/views/cards/comments/reactions/_reaction.html.erb +++ b/app/views/cards/comments/reactions/_reaction.html.erb @@ -15,7 +15,7 @@ class: [ "txt-small", { "txt-medium": reaction.all_emoji? } ], data: { action: "click->reaction-delete#reveal keydown.enter->reaction-delete#reveal:prevent", reaction_delete_target: "content" } %> - <%= button_to card_comment_reaction_path(reaction.comment.card, reaction.comment, reaction), + <%= button_to card_comment_reaction_path(reaction.reactable.card, reaction.reactable, reaction), method: :delete, class: "reaction__delete btn btn--negative flex-item-justify-end", data: { action: "reaction-delete#perform", reaction_delete_target: "button" } do %> diff --git a/app/views/cards/comments/reactions/_reaction.json.jbuilder b/app/views/cards/comments/reactions/_reaction.json.jbuilder index 17ad70f70..ad50825fb 100644 --- a/app/views/cards/comments/reactions/_reaction.json.jbuilder +++ b/app/views/cards/comments/reactions/_reaction.json.jbuilder @@ -1,5 +1,5 @@ json.cache! reaction do json.(reaction, :id, :content) json.reacter reaction.reacter, partial: "users/user", as: :user - json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction) + json.url card_comment_reaction_url(reaction.reactable.card, reaction.reactable, reaction) end diff --git a/test/fixtures/reactions.yml b/test/fixtures/reactions.yml index 16714c601..c505ab076 100644 --- a/test/fixtures/reactions.yml +++ b/test/fixtures/reactions.yml @@ -2,12 +2,12 @@ kevin: id: <%= ActiveRecord::FixtureSet.identify("kevin_reaction", :uuid) %> account: 37s_uuid content: "👍" - comment: logo_agreement_jz_uuid + reactable: logo_agreement_jz_uuid (Comment) reacter: kevin_uuid david: id: <%= ActiveRecord::FixtureSet.identify("david_reaction", :uuid) %> account: 37s_uuid content: "👍" - comment: logo_agreement_jz_uuid + reactable: logo_agreement_jz_uuid (Comment) reacter: david_uuid From 23fe40fa8159534bab63298ceb27a8676d62c81d Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 22 Jan 2026 20:03:02 +0000 Subject: [PATCH 07/26] Correctly initialise WebPush connection (#2417) The `ipaddr` arg here is being interpreted as a positional arg (since the keyword arg doesn't exist), which results in an invalid connection object. This was causing push notifications to silently fail. We should initialise the property on the object instead. --- config/initializers/web_push.rb | 3 +- test/lib/web_push/persistent_request_test.rb | 26 ++++++++++++ test/test_helper.rb | 1 + test/webmock_ipaddr_extension.rb | 44 ++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 test/lib/web_push/persistent_request_test.rb create mode 100644 test/webmock_ipaddr_extension.rb diff --git a/config/initializers/web_push.rb b/config/initializers/web_push.rb index 6914dae22..4487268f5 100644 --- a/config/initializers/web_push.rb +++ b/config/initializers/web_push.rb @@ -20,7 +20,8 @@ module WebPush::PersistentRequest endpoint_ip = @options[:endpoint_ip] if endpoint_ip - http = Net::HTTP.new(uri.host, uri.port, ipaddr: endpoint_ip) + http = Net::HTTP.new(uri.host, uri.port) + http.ipaddr = endpoint_ip http.use_ssl = true http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil? http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil? diff --git a/test/lib/web_push/persistent_request_test.rb b/test/lib/web_push/persistent_request_test.rb new file mode 100644 index 000000000..2cf8a6c7c --- /dev/null +++ b/test/lib/web_push/persistent_request_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class WebPush::PersistentRequestTest < ActiveSupport::TestCase + PUBLIC_TEST_IP = "142.250.185.206" + ENDPOINT = "https://fcm.googleapis.com/fcm/send/test123" + + test "pins connection to endpoint_ip" do + request = stub_request(:post, ENDPOINT) + .with(ipaddr: PUBLIC_TEST_IP) + .to_return(status: 201) + + notification = WebPush::Notification.new( + title: "Test", + body: "Test notification", + path: "/test", + badge: 0, + endpoint: ENDPOINT, + endpoint_ip: PUBLIC_TEST_IP, + p256dh_key: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM", + auth_key: "tBHItJI5svbpez7KI4CCXg" + ) + notification.deliver + + assert_requested request + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 14893b030..b1f813761 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,6 +3,7 @@ require_relative "../config/environment" require "rails/test_help" require "webmock/minitest" +require_relative "webmock_ipaddr_extension" require "vcr" require "mocha/minitest" require "turbo/broadcastable/test_helper" diff --git a/test/webmock_ipaddr_extension.rb b/test/webmock_ipaddr_extension.rb new file mode 100644 index 000000000..874654f87 --- /dev/null +++ b/test/webmock_ipaddr_extension.rb @@ -0,0 +1,44 @@ +# Extends WebMock to support ipaddr matching for testing IP pinning. +# +# Usage: +# stub_request(:post, "https://example.com/push") +# .with(ipaddr: "93.184.216.34") +# .to_return(status: 201) +# +# If the HTTP connection's ipaddr doesn't match, the stub won't match and +# WebMock will raise an error about an unregistered request. + +module WebMock + class RequestSignature + attr_accessor :ipaddr + end + + module RequestPatternIpaddrExtension + attr_accessor :ipaddr_pattern + + def assign_options(options) + options = options.dup + @ipaddr_pattern = options.delete(:ipaddr) || options.delete("ipaddr") + super(options) + end + + def matches?(request_signature) + super && ipaddr_matches?(request_signature) + end + + private + def ipaddr_matches?(request_signature) + @ipaddr_pattern.nil? || @ipaddr_pattern == request_signature.ipaddr + end + end + + RequestPattern.prepend RequestPatternIpaddrExtension + + module NetHTTPUtilityIpaddrExtension + def request_signature_from_request(net_http, request, body = nil) + super.tap { |signature| signature.ipaddr = net_http.ipaddr } + end + end + + NetHTTPUtility.singleton_class.prepend NetHTTPUtilityIpaddrExtension +end From d7c9c4f3b0212b2a90642cdb5db89b50b1dfec72 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Fri, 23 Jan 2026 11:59:27 +0000 Subject: [PATCH 08/26] Remove unnecessary `await` in push handler We were `await`ing a synchronous method here, which will have the effect of deferring its execution, and thus also the `event.waitUntil` call. We want the latter to happen before control returns from the event. Removing the unnecessary `await` solves this. This seems unlikely to be a problem in practice, but all the same, it's not right :) --- app/views/pwa/service_worker.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js index df284d578..1216c550d 100644 --- a/app/views/pwa/service_worker.js +++ b/app/views/pwa/service_worker.js @@ -9,8 +9,8 @@ self.addEventListener('fetch', (event) => { } }) -self.addEventListener("push", async (event) => { - const data = await event.data.json() +self.addEventListener("push", (event) => { + const data = event.data.json() event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ])) }) From e27456b8f8d603a323b71bd636ab858c7534cd27 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Fri, 23 Jan 2026 14:16:46 +0000 Subject: [PATCH 09/26] Include arm64 build in Docker workflow --- .github/workflows/publish-image.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index bbd1eb22a..d89381607 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -34,6 +34,9 @@ jobs: - runner: ubuntu-latest platform: linux/amd64 arch: amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + arch: arm64 env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -170,11 +173,13 @@ jobs: docker buildx imagetools create \ --tag "$tag" \ --annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \ - "${src_tag}-amd64" + "${src_tag}-amd64" \ + "${src_tag}-arm64" else docker buildx imagetools create \ --tag "$tag" \ - "${src_tag}-amd64" + "${src_tag}-amd64" \ + "${src_tag}-arm64" fi done <<< "$tags" From 1aea6850f0f743332abf6cae6bab94908625307c Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 23 Jan 2026 10:26:50 -0500 Subject: [PATCH 10/26] Extract Card::Commentable --- app/models/card.rb | 54 +------------------------- app/models/card/commentable.rb | 57 ++++++++++++++++++++++++++++ test/models/card/commentable_test.rb | 8 ++++ test/models/card_test.rb | 8 ---- 4 files changed, 67 insertions(+), 60 deletions(-) create mode 100644 app/models/card/commentable.rb diff --git a/app/models/card.rb b/app/models/card.rb index d76512093..43e2d9361 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,13 +1,12 @@ class Card < ApplicationRecord - include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, - Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, + include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable, + Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } belongs_to :board belongs_to :creator, class_name: "User", default: -> { Current.user } - has_many :comments, dependent: :destroy has_one_attached :image, dependent: :purge_later has_rich_text :description @@ -66,55 +65,6 @@ class Card < ApplicationRecord end private - STORAGE_BATCH_SIZE = 1000 - - # Override to include comments, but only load comments that have attachments. - # Cards can have thousands of comments; most won't have attachments. - # Streams in batches to avoid loading all IDs into memory at once. - def storage_transfer_records - comment_ids_with_attachments = storage_comment_ids_with_attachments - - if comment_ids_with_attachments.any? - [ self, *comments.where(id: comment_ids_with_attachments).to_a ] - else - [ self ] - end - end - - def storage_comment_ids_with_attachments - direct = [] - rich_text_map = {} - - # Stream comment IDs in batches to avoid loading all into memory - comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch| - batch_ids = batch.pluck(:id) - - direct.concat \ - ActiveStorage::Attachment - .where(record_type: "Comment", record_id: batch_ids) - .distinct - .pluck(:record_id) - - ActionText::RichText - .where(record_type: "Comment", record_id: batch_ids) - .pluck(:id, :record_id) - .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id } - end - - embed_comment_ids = if rich_text_map.any? - rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids| - ActiveStorage::Attachment - .where(record_type: "ActionText::RichText", record_id: batch_ids) - .distinct - .pluck(:record_id) - end.filter_map { |rt_id| rich_text_map[rt_id] } - else - [] - end - - (direct + embed_comment_ids).uniq - end - def set_default_title self.title = "Untitled" if title.blank? end diff --git a/app/models/card/commentable.rb b/app/models/card/commentable.rb new file mode 100644 index 000000000..5c07db47e --- /dev/null +++ b/app/models/card/commentable.rb @@ -0,0 +1,57 @@ +module Card::Commentable + extend ActiveSupport::Concern + + included do + has_many :comments, dependent: :destroy + end + + private + STORAGE_BATCH_SIZE = 1000 + + # Override to include comments, but only load comments that have attachments. + # Cards can have thousands of comments; most won't have attachments. + # Streams in batches to avoid loading all IDs into memory at once. + def storage_transfer_records + comment_ids_with_attachments = storage_comment_ids_with_attachments + + if comment_ids_with_attachments.any? + [ self, *comments.where(id: comment_ids_with_attachments).to_a ] + else + [ self ] + end + end + + def storage_comment_ids_with_attachments + direct = [] + rich_text_map = {} + + # Stream comment IDs in batches to avoid loading all into memory + comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch| + batch_ids = batch.pluck(:id) + + direct.concat \ + ActiveStorage::Attachment + .where(record_type: "Comment", record_id: batch_ids) + .distinct + .pluck(:record_id) + + ActionText::RichText + .where(record_type: "Comment", record_id: batch_ids) + .pluck(:id, :record_id) + .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id } + end + + embed_comment_ids = if rich_text_map.any? + rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids| + ActiveStorage::Attachment + .where(record_type: "ActionText::RichText", record_id: batch_ids) + .distinct + .pluck(:record_id) + end.filter_map { |rt_id| rich_text_map[rt_id] } + else + [] + end + + (direct + embed_comment_ids).uniq + end +end diff --git a/test/models/card/commentable_test.rb b/test/models/card/commentable_test.rb index 179828b3b..f122e3c46 100644 --- a/test/models/card/commentable_test.rb +++ b/test/models/card/commentable_test.rb @@ -1,6 +1,14 @@ require "test_helper" class Card::CommentableTest < ActiveSupport::TestCase + test "capturing comments" do + assert_difference -> { cards(:logo).comments.count }, +1 do + cards(:logo).comments.create!(body: "Agreed.") + end + + assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp + end + test "creating a comment on a card makes the creator watch the card" do boards(:writebook).access_for(users(:kevin)).access_only! assert_not cards(:text).watched_by?(users(:kevin)) diff --git a/test/models/card_test.rb b/test/models/card_test.rb index bb72fddf8..55b1dc602 100644 --- a/test/models/card_test.rb +++ b/test/models/card_test.rb @@ -18,14 +18,6 @@ class CardTest < ActiveSupport::TestCase assert_equal account.reload.cards_count, card.number end - test "capturing messages" do - assert_difference -> { cards(:logo).comments.count }, +1 do - cards(:logo).comments.create!(body: "Agreed.") - end - - assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp - end - test "assignment states" do assert cards(:logo).assigned_to?(users(:kevin)) assert_not cards(:logo).assigned_to?(users(:david)) From c650cba17942ff9e940c2c88f26a4b756d9abc14 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 13:29:33 +0100 Subject: [PATCH 11/26] Fix stuck state when permission granted but no subscription When you had already granted notification permission but hadn't completed the subscription flow (no service worker or no push subscription), the UI showed neither the subscribe button nor the enabled state, leaving you stuck with no way to subscribe, and wrong instructions to fix it. Instead, let's just show the button to allow you to subscribe. --- app/javascript/controllers/notifications_controller.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 02fce86d7..304bb279d 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -11,8 +11,7 @@ export default class extends Controller { switch(Notification.permission) { case "default": - this.subscribeButtonTarget.hidden = false - this.explainerTarget.hidden = true + this.#showButtonToSubscribe() break case "granted": const registration = await this.#getServiceWorkerRegistration() @@ -20,6 +19,8 @@ export default class extends Controller { if (registration && subscription) { this.element.classList.add(this.enabledClass) + } else { + this.#showButtonToSubscribe() } break } @@ -76,6 +77,11 @@ export default class extends Controller { } } + #showButtonToSubscribe() { + this.subscribeButtonTarget.hidden = false + this.explainerTarget.hidden = true + } + async #requestPermissionAndSubscribe(registration) { const permission = await Notification.requestPermission() if (permission === "granted") this.#subscribe(registration) From 26d5701217434c6d5a58543edb5fc3373feffefe Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 13:40:19 +0100 Subject: [PATCH 12/26] Wait for service worker to be active before subscribing The push subscription requires an active service worker. When registering a new service worker, we now wait for it to become active using navigator.serviceWorker.ready before attempting to subscribe to push notifications. Co-Authored-By: Claude Opus 4.5 --- app/javascript/controllers/notifications_controller.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 304bb279d..7104cfa8b 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -55,8 +55,9 @@ export default class extends Controller { return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" }) } - #registerServiceWorker() { - return navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) + async #registerServiceWorker() { + await navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) + return navigator.serviceWorker.ready } async #subscribe(registration) { From 4f2308d380b1118523886cbaef190ef31f7087dd Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 13:52:28 +0100 Subject: [PATCH 13/26] Fix notification click URL by using correct data property The web push payload sends the URL in data.url but the service worker was looking for data.path, resulting in undefined URLs. Co-Authored-By: Claude Opus 4.5 --- app/views/pwa/service_worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js index 1216c550d..91903e735 100644 --- a/app/views/pwa/service_worker.js +++ b/app/views/pwa/service_worker.js @@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) { self.addEventListener("notificationclick", (event) => { event.notification.close() - const url = new URL(event.notification.data.path, self.location.origin).href + const url = new URL(event.notification.data.url, self.location.origin).href event.waitUntil(openURL(url)) }) From bb2d3a59b5ce392e68b3eb13d1eb2f51c547c8ee Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 22 Jan 2026 14:09:03 -0500 Subject: [PATCH 14/26] prefactor: update search to use published cards We're about to make a change to assert that draft cards are not added to the search index, and so let's make the intention behind these tests clear first. --- test/controllers/searches_controller_test.rb | 9 +++++---- test/models/card/searchable_test.rb | 14 +++++++------- test/models/comment/searchable_test.rb | 6 +++--- test/models/filter/search_test.rb | 3 +-- test/models/search_test.rb | 8 ++++---- test/test_helpers/search_test_helper.rb | 1 + 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/test/controllers/searches_controller_test.rb b/test/controllers/searches_controller_test.rb index 70ec15da9..2493fe43f 100644 --- a/test/controllers/searches_controller_test.rb +++ b/test/controllers/searches_controller_test.rb @@ -5,10 +5,10 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest setup do @board.update!(all_access: true) - @card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", creator: @user) - @comment_card = @board.cards.create!(title: "Some card", creator: @user) + @card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", status: "published", creator: @user) + @comment_card = @board.cards.create!(title: "Some card", status: "published", creator: @user) @comment_card.comments.create!(body: "overflowing text issue", creator: @user) - @comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", creator: @user) + @comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", status: "published", creator: @user) @comment2_card.comments.create!(body: "I love haggis", creator: @user) untenanted { sign_in_as @user } @@ -43,7 +43,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest end test "search highlights matched terms with proper HTML marks" do - @board.cards.create!(title: "Testing search highlighting", creator: @user) + @board.cards.create!(title: "Testing search highlighting", status: "published", creator: @user) get search_path(q: "highlighting", script_name: "/#{@account.external_account_id}") assert_response :success @@ -52,6 +52,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest test "search preserves highlight marks but escapes surrounding HTML" do @board.cards.create!( title: "Bold testing content", + status: "published", creator: @user ) diff --git a/test/models/card/searchable_test.rb b/test/models/card/searchable_test.rb index 5c7ca9c86..a67890620 100644 --- a/test/models/card/searchable_test.rb +++ b/test/models/card/searchable_test.rb @@ -5,18 +5,18 @@ class Card::SearchableTest < ActiveSupport::TestCase test "card search" do # Searching by title - card = @board.cards.create!(title: "layout is broken", creator: @user) + card = @board.cards.create!(title: "layout is broken", status: "published", creator: @user) results = Card.mentioning("layout", user: @user) assert_includes results, card # Searching by comment - card_with_comment = @board.cards.create!(title: "Some card", creator: @user) + card_with_comment = @board.cards.create!(title: "Some card", status: "published", creator: @user) card_with_comment.comments.create!(body: "overflowing text", creator: @user) results = Card.mentioning("overflowing", user: @user) assert_includes results, card_with_comment # Sanitizing search query - card_broken = @board.cards.create!(title: "broken layout", creator: @user) + card_broken = @board.cards.create!(title: "broken layout", status: "published", creator: @user) results = Card.mentioning("broken \"", user: @user) assert_includes results, card_broken @@ -25,8 +25,8 @@ class Card::SearchableTest < ActiveSupport::TestCase # Filtering by board_ids other_board = Board.create!(name: "Other Board", account: @account, creator: @user) - card_in_board = @board.cards.create!(title: "searchable content", creator: @user) - card_in_other_board = other_board.cards.create!(title: "searchable content", creator: @user) + card_in_board = @board.cards.create!(title: "searchable content", status: "published", creator: @user) + card_in_other_board = other_board.cards.create!(title: "searchable content", status: "published", creator: @user) results = Card.mentioning("searchable", user: @user) assert_includes results, card_in_board assert_not_includes results, card_in_other_board @@ -37,7 +37,7 @@ class Card::SearchableTest < ActiveSupport::TestCase # Create a card with unreasonably long content long_content = "asdf " * Searchable::SEARCH_CONTENT_LIMIT - card = @board.cards.create!(title: "Card with long description", creator: @user) + card = @board.cards.create!(title: "Card with long description", status: "published", creator: @user) card.description = ActionText::Content.new(long_content) card.save! @@ -52,7 +52,7 @@ class Card::SearchableTest < ActiveSupport::TestCase test "deleting card removes search record and FTS entry" do search_record_class = Search::Record.for(@user.account_id) - card = @board.cards.create!(title: "Card to delete", creator: @user) + card = @board.cards.create!(title: "Card to delete", status: "published", creator: @user) # Verify search record exists search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id) diff --git a/test/models/comment/searchable_test.rb b/test/models/comment/searchable_test.rb index 7ba73847c..c1a62e607 100644 --- a/test/models/comment/searchable_test.rb +++ b/test/models/comment/searchable_test.rb @@ -4,7 +4,7 @@ class Comment::SearchableTest < ActiveSupport::TestCase include SearchTestHelper setup do - @card = @board.cards.create!(title: "Test Card", creator: @user) + @card = @board.cards.create!(title: "Test Card", status: "published", creator: @user) end test "comment search" do @@ -40,9 +40,9 @@ class Comment::SearchableTest < ActiveSupport::TestCase end # Finding cards via comment search - card_with_comment = @board.cards.create!(title: "Card One", creator: @user) + card_with_comment = @board.cards.create!(title: "Card One", status: "published", creator: @user) card_with_comment.comments.create!(body: "unique searchable phrase", creator: @user) - card_without_comment = @board.cards.create!(title: "Card Two", creator: @user) + card_without_comment = @board.cards.create!(title: "Card Two", status: "published", creator: @user) results = Card.mentioning("searchable", user: @user) assert_includes results, card_with_comment assert_not_includes results, card_without_comment diff --git a/test/models/filter/search_test.rb b/test/models/filter/search_test.rb index f7b7e7968..cbdd9558d 100644 --- a/test/models/filter/search_test.rb +++ b/test/models/filter/search_test.rb @@ -4,8 +4,7 @@ class Filter::SearchTest < ActiveSupport::TestCase include SearchTestHelper test "deduplicate multiple results" do - card = @board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: @user) - card.published! + card = @board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: @user, status: "published") card.comments.create(body: "I hate haggis.", creator: @user) card.comments.create(body: "I love haggis.", creator: @user) diff --git a/test/models/search_test.rb b/test/models/search_test.rb index 1f4981090..343295dd0 100644 --- a/test/models/search_test.rb +++ b/test/models/search_test.rb @@ -5,8 +5,8 @@ class SearchTest < ActiveSupport::TestCase test "search" do # Search cards and comments - card = @board.cards.create!(title: "layout design", creator: @user) - comment_card = @board.cards.create!(title: "Some card", creator: @user) + card = @board.cards.create!(title: "layout design", creator: @user, status: "published") + comment_card = @board.cards.create!(title: "Some card", creator: @user, status: "published") comment_card.comments.create!(body: "overflowing text", creator: @user) results = Search::Record.for(@user.account_id).search("layout", user: @user) @@ -18,8 +18,8 @@ class SearchTest < ActiveSupport::TestCase # Don't include inaccessible boards other_user = User.create!(name: "Other User", account: @account) inaccessible_board = Board.create!(name: "Inaccessible Board", account: @account, creator: other_user) - accessible_card = @board.cards.create!(title: "searchable content", creator: @user) - inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user) + accessible_card = @board.cards.create!(title: "searchable content", creator: @user, status: "published") + inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user, status: "published") results = Search::Record.for(@user.account_id).search("searchable", user: @user) assert results.find { |it| it.card_id == accessible_card.id } diff --git a/test/test_helpers/search_test_helper.rb b/test/test_helpers/search_test_helper.rb index 78b113781..81cf271dc 100644 --- a/test/test_helpers/search_test_helper.rb +++ b/test/test_helpers/search_test_helper.rb @@ -17,6 +17,7 @@ module SearchTestHelper Current.account = @account @identity = Identity.create!(email_address: "test@example.com") @user = User.create!(name: "Test User", account: @account, identity: @identity) + Current.user = @user @board = Board.create!(name: "Test Board", account: @account, creator: @user) end From 9aa15e9fb2707a93eb913587657d643825b6f976 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 23 Jan 2026 11:28:15 -0500 Subject: [PATCH 15/26] Forbid comments on draft cards - Add Card#commentable? method that returns true only for published cards. - CardsController#create ensures that the card is commentable. Co-Authored-By: Claude Opus 4.5 --- app/controllers/cards/comments_controller.rb | 5 +++++ app/models/card/commentable.rb | 4 ++++ app/models/comment.rb | 6 ++++++ test/controllers/cards/comments_controller_test.rb | 10 ++++++++++ test/models/card/commentable_test.rb | 9 +++++++++ test/models/comment_test.rb | 13 +++++++++++++ test/models/concerns/mentions_test.rb | 10 ---------- 7 files changed, 47 insertions(+), 10 deletions(-) diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb index 27c507abe..f2b318813 100644 --- a/app/controllers/cards/comments_controller.rb +++ b/app/controllers/cards/comments_controller.rb @@ -3,6 +3,7 @@ class Cards::CommentsController < ApplicationController before_action :set_comment, only: %i[ show edit update destroy ] before_action :ensure_creatorship, only: %i[ edit update destroy ] + before_action :ensure_card_is_commentable, only: :create def index set_page_and_extract_portion_from @card.comments.chronologically @@ -50,6 +51,10 @@ class Cards::CommentsController < ApplicationController head :forbidden if Current.user != @comment.creator end + def ensure_card_is_commentable + head :forbidden unless @card.commentable? + end + def comment_params params.expect(comment: [ :body, :created_at ]) end diff --git a/app/models/card/commentable.rb b/app/models/card/commentable.rb index 5c07db47e..4296a77a7 100644 --- a/app/models/card/commentable.rb +++ b/app/models/card/commentable.rb @@ -5,6 +5,10 @@ module Card::Commentable has_many :comments, dependent: :destroy end + def commentable? + published? + end + private STORAGE_BATCH_SIZE = 1000 diff --git a/app/models/comment.rb b/app/models/comment.rb index 41e068514..70a36562f 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -8,6 +8,8 @@ class Comment < ApplicationRecord has_rich_text :body + validate :card_is_commentable + scope :chronologically, -> { order created_at: :asc, id: :desc } scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) } scope :by_system, -> { joins(:creator).where(creator: { role: :system }) } @@ -22,6 +24,10 @@ class Comment < ApplicationRecord end private + def card_is_commentable + errors.add(:card, "does not allow comments") unless card.commentable? + end + def watch_card_by_creator card.watch_by creator end diff --git a/test/controllers/cards/comments_controller_test.rb b/test/controllers/cards/comments_controller_test.rb index ef6dbf6e2..0103a846f 100644 --- a/test/controllers/cards/comments_controller_test.rb +++ b/test/controllers/cards/comments_controller_test.rb @@ -13,6 +13,16 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "create on draft card is forbidden" do + draft_card = boards(:writebook).cards.create!(status: :drafted, creator: users(:kevin)) + + assert_no_difference -> { draft_card.comments.count } do + post card_comments_path(draft_card), params: { comment: { body: "This should be forbidden" } }, as: :json + end + + assert_response :forbidden + end + test "update" do put card_comment_path(cards(:logo), comments(:logo_agreement_kevin)), params: { comment: { body: "I've changed my mind" } }, as: :turbo_stream diff --git a/test/models/card/commentable_test.rb b/test/models/card/commentable_test.rb index f122e3c46..8b3c93f4d 100644 --- a/test/models/card/commentable_test.rb +++ b/test/models/card/commentable_test.rb @@ -1,6 +1,10 @@ require "test_helper" class Card::CommentableTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + end + test "capturing comments" do assert_difference -> { cards(:logo).comments.count }, +1 do cards(:logo).comments.create!(body: "Agreed.") @@ -19,4 +23,9 @@ class Card::CommentableTest < ActiveSupport::TestCase assert cards(:text).watched_by?(users(:kevin)) end + + test "commentable is true for published cards, false for drafts" do + assert cards(:logo).commentable? + assert_not cards(:unfinished_thoughts).commentable? + end end diff --git a/test/models/comment_test.rb b/test/models/comment_test.rb index 13a5e36c3..bd2089f0e 100644 --- a/test/models/comment_test.rb +++ b/test/models/comment_test.rb @@ -5,6 +5,19 @@ class CommentTest < ActiveSupport::TestCase Current.session = sessions(:david) end + test "cannot create comment on a draft card" do + draft_card = cards(:unfinished_thoughts) + + comment = draft_card.comments.build(body: "This should fail") + + assert_not comment.valid? + assert_includes comment.errors[:card], "does not allow comments" + + assert_raises(ActiveRecord::RecordInvalid) do + draft_card.comments.create!(body: "This should raise") + end + end + test "rich text embed variants are processed immediately on attachment" do comment = cards(:logo).comments.create!(body: "Check this out") comment.body.body.attachables # force load diff --git a/test/models/concerns/mentions_test.rb b/test/models/concerns/mentions_test.rb index 48b94e6f1..fca379bf1 100644 --- a/test/models/concerns/mentions_test.rb +++ b/test/models/concerns/mentions_test.rb @@ -70,16 +70,6 @@ class MentionsTest < ActiveSupport::TestCase end end - test "don't create mentions from comments when belonging to unpublished cards" do - perform_enqueued_jobs only: Mention::CreateJob do - card = boards(:writebook).cards.create title: "Cleanup", description: "Some initial content" - - assert_no_difference -> { Mention.count } do - card.comments.create!(body: "Great work on this #{mention_html_for(users(:david))}!") - end - end - end - test "can't mention users that don't have access to the board" do boards(:writebook).update! all_access: false boards(:writebook).accesses.revoke_from(users(:david)) From 0d138df952dfe6541cef86882445d0c874d45b64 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 22 Jan 2026 14:17:44 -0500 Subject: [PATCH 16/26] Guard search indexing with searchable? check Only index cards and comments if their card is `published?`, using a new method `searchable?` that is implemented on both Card and Comment. This prevents draft cards and their comments from being indexed. When drafts are published, the existing `update_in_search_index` callback creates the record via `upsert!`, or else ensures unpublished records are removed from the index. Co-Authored-By: Claude Opus 4.5 --- app/models/card/searchable.rb | 4 ++ app/models/comment/searchable.rb | 4 ++ app/models/concerns/searchable.rb | 11 +++++- test/models/card/searchable_test.rb | 52 ++++++++++++++++++++++++++ test/models/comment/searchable_test.rb | 11 ++++++ test/models/search_test.rb | 5 +++ 6 files changed, 85 insertions(+), 2 deletions(-) diff --git a/app/models/card/searchable.rb b/app/models/card/searchable.rb index 8cc108b23..d5f53205d 100644 --- a/app/models/card/searchable.rb +++ b/app/models/card/searchable.rb @@ -25,4 +25,8 @@ module Card::Searchable def search_board_id board_id end + + def searchable? + published? + end end diff --git a/app/models/comment/searchable.rb b/app/models/comment/searchable.rb index 626d2be64..3194f9042 100644 --- a/app/models/comment/searchable.rb +++ b/app/models/comment/searchable.rb @@ -20,4 +20,8 @@ module Comment::Searchable def search_board_id card.board_id end + + def searchable? + card.published? + end end diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index 17d1f186a..2be34fe8d 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -15,11 +15,17 @@ module Searchable private def create_in_search_index - search_record_class.create!(search_record_attributes) + if searchable? + search_record_class.create!(search_record_attributes) + end end def update_in_search_index - search_record_class.upsert!(search_record_attributes) + if searchable? + search_record_class.upsert!(search_record_attributes) + else + remove_from_search_index + end end def remove_from_search_index @@ -53,4 +59,5 @@ module Searchable # - search_content: returns content string # - search_card_id: returns the card id (self.id for cards, card_id for comments) # - search_board_id: returns the board id + # - searchable?: returns whether this record should be indexed end diff --git a/test/models/card/searchable_test.rb b/test/models/card/searchable_test.rb index a67890620..bf3de621f 100644 --- a/test/models/card/searchable_test.rb +++ b/test/models/card/searchable_test.rb @@ -3,6 +3,16 @@ require "test_helper" class Card::SearchableTest < ActiveSupport::TestCase include SearchTestHelper + test "searchable? returns true for published cards" do + card = @board.cards.create!(title: "Published Card", status: "published", creator: @user) + assert card.searchable? + end + + test "searchable? returns false for draft cards" do + card = @board.cards.create!(title: "Draft Card", status: "drafted", creator: @user) + assert_not card.searchable? + end + test "card search" do # Searching by title card = @board.cards.create!(title: "layout is broken", status: "published", creator: @user) @@ -78,4 +88,46 @@ class Card::SearchableTest < ActiveSupport::TestCase assert_equal 0, fts_count, "FTS entry should be deleted" end end + + test "updating a draft card does not index it" do + search_record_class = Search::Record.for(@user.account_id) + + card = @board.cards.create!(title: "Draft card", creator: @user, status: "drafted") + assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id) + + card.update!(title: "Updated draft card") + assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id), + "Draft card should not be indexed after update" + + results = Card.mentioning("Updated", user: @user) + assert_not_includes results, card + end + + test "publishing a draft card indexes it" do + search_record_class = Search::Record.for(@user.account_id) + + card = @board.cards.create!(title: "Draft to publish", creator: @user, status: "drafted") + assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id) + + card.publish + search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id) + assert_not_nil search_record, "Published card should be indexed" + assert_equal card.id, search_record.card_id + + results = Card.mentioning("publish", user: @user) + assert_includes results, card + end + + test "unpublishing a draft card removes it from the search index" do + search_record_class = Search::Record.for(@user.account_id) + + card = @board.cards.create!(title: "Draft to publish", creator: @user, status: "published") + assert_not_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id) + + card.update!(status: "drafted") + + assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id) + results = Card.mentioning("publish", user: @user) + assert_not_includes results, card + end end diff --git a/test/models/comment/searchable_test.rb b/test/models/comment/searchable_test.rb index c1a62e607..b867c9d92 100644 --- a/test/models/comment/searchable_test.rb +++ b/test/models/comment/searchable_test.rb @@ -7,6 +7,17 @@ class Comment::SearchableTest < ActiveSupport::TestCase @card = @board.cards.create!(title: "Test Card", status: "published", creator: @user) end + test "searchable? returns true for comments on published cards" do + comment = @card.comments.create!(body: "test comment", creator: @user) + assert comment.searchable? + end + + test "searchable? returns false for comments on draft cards" do + draft_card = @board.cards.create!(title: "Draft Card", status: "drafted", creator: @user) + comment = draft_card.comments.build(body: "test comment", creator: @user) + assert_not comment.searchable? + end + test "comment search" do search_record_class = Search::Record.for(@user.account_id) # Comment is indexed on create diff --git a/test/models/search_test.rb b/test/models/search_test.rb index 343295dd0..44996404b 100644 --- a/test/models/search_test.rb +++ b/test/models/search_test.rb @@ -15,6 +15,11 @@ class SearchTest < ActiveSupport::TestCase results = Search::Record.for(@user.account_id).search("overflowing", user: @user) assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" } + # Drafted cards are excluded from search results + drafted_card = @board.cards.create!(title: "drafted searchable content", creator: @user, status: "drafted") + results = Search::Record.for(@user.account_id).search("drafted", user: @user) + assert_not results.find { |it| it.card_id == drafted_card.id } + # Don't include inaccessible boards other_user = User.create!(name: "Other User", account: @account) inaccessible_board = Board.create!(name: "Inaccessible Board", account: @account, creator: other_user) From a9c6bc2a283b45630f06633606a03d7a4a7a8544 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 23 Jan 2026 10:57:50 -0500 Subject: [PATCH 17/26] Add migration to remove draft cards from search index One-time script to clean up any search records that were indexed before the searchable? guard was added. Co-Authored-By: Claude Opus 4.5 --- ...23-remove-draft-cards-from-search-index.rb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100755 script/migrations/20260123-remove-draft-cards-from-search-index.rb diff --git a/script/migrations/20260123-remove-draft-cards-from-search-index.rb b/script/migrations/20260123-remove-draft-cards-from-search-index.rb new file mode 100755 index 000000000..35afd98e8 --- /dev/null +++ b/script/migrations/20260123-remove-draft-cards-from-search-index.rb @@ -0,0 +1,22 @@ +#!/usr/bin/env ruby + +require_relative "../../config/environment" + +total_deleted = 0 + +Account.find_each do |account| + search_record_class = Search::Record.for(account.id) + + # Find search records for draft cards (both Card and Comment searchables) + draft_card_ids = Card.where(account_id: account.id, status: "drafted").pluck(:id) + + if draft_card_ids.any? + count = search_record_class.where(card_id: draft_card_ids).delete_all + if count > 0 + puts "#{account.name}: deleted #{count} search records for draft cards" + total_deleted += count + end + end +end + +puts "Migration completed! Total deleted: #{total_deleted}" From ffb2a0b82ae1639e14d9b6808bfe1ebabae68ca6 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 23 Jan 2026 19:25:23 +0100 Subject: [PATCH 18/26] Revert "Fix notification click URL by using correct data property" This reverts commit 4f2308d380b1118523886cbaef190ef31f7087dd. This was from another branch, shoudln't have been included here. --- app/views/pwa/service_worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js index 91903e735..1216c550d 100644 --- a/app/views/pwa/service_worker.js +++ b/app/views/pwa/service_worker.js @@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) { self.addEventListener("notificationclick", (event) => { event.notification.close() - const url = new URL(event.notification.data.url, self.location.origin).href + const url = new URL(event.notification.data.path, self.location.origin).href event.waitUntil(openURL(url)) }) From cb61b36715ac21fb06c544b1c1409d3c6044241a Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 23 Jan 2026 13:25:55 -0500 Subject: [PATCH 19/26] Allow boosts on cards (#2411) * Add reactions association to Card model Adds `has_many :reactions` to Card, matching the implementation in Comment. This allows cards to be reacted to directly with emoji reactions. The association: - Orders reactions chronologically - Uses polymorphic `:reactable` interface - Deletes reactions when card is destroyed Includes test coverage for the new association. Co-Authored-By: Claude Opus 4.5 * Add reactions UI to card detail view - Create Cards::ReactionsController with turbo stream responses - Add card reactions views (reactions list, new form, menu partial) - Position reaction button flush-right when no reactions exist - Show reactions on bottom-left when present, with button inline - Handle narrow viewports by flowing button below meta section - Add controller tests for card reactions Co-Authored-By: Claude Opus 4.5 * Add boost count to card preview Display reaction count alongside comment count on card tiles in board columns. Introduces a .card__counts wrapper to group both counts with proper positioning on all viewport sizes. Co-Authored-By: Claude Opus 4.5 * Consolidate reaction views for cards and comments Extract shared views to app/views/reactions/ using polymorphic routing. Both card and comment reactions now render the same partials, with a helper to compute the correct path prefix for nested routes. Co-Authored-By: Claude Opus 4.5 * Fix emoji picker width in card reactions Override .card .popup { inline-size: 260px } for the reaction popup so the emoji grid displays without horizontal scrollbar. Co-Authored-By: Claude Opus 4.5 * Hide boost count where comment count is hidden Add .card__boosts alongside .card__comments in: - Tray view (display: none) - Cards with background images (opacity toggle on hover) Co-Authored-By: Claude Opus 4.5 * Fix reactions button overlap with zoom button in card footer When a card has a background image, the footer contains both a reactions button and a zoom button. Previously they would overlap because the reactions button was absolutely positioned to the bottom-right. Co-Authored-By: Claude Opus 4.5 * Tighten up previews * Move card reactions from meta partial to container Fixes two issues: 1. Reactions were duplicated each time a card was assigned because the turbo stream replaced the meta div with the full perma/meta partial, which included reactions outside the replaced element. 2. Draft cards incorrectly showed the boost button because the meta partial was shared between published and draft containers. Moving reactions to _container.html.erb (published cards only) fixes both issues and keeps the meta partial focused on meta content. Fixes https://app.fizzy.do/5986089/cards/3837 Fixes https://app.fizzy.do/5986089/cards/3835 Co-Authored-By: Claude Opus 4.5 * WIP adjust perma page * Position and style the footer * Render the stamp in the card body instead of the footer * Fix undefined local variable in public cards view Changed `card` to `@card` when rendering the stamp partial. Co-Authored-By: Claude Opus 4.5 * Preload card reactions to avoid N+1 queries Include reactions and their reacters in the preloaded scope, matching what we already do for comments. Co-Authored-By: Claude Opus 4.5 * Add model tests for card and comment reaction cleanup Test that: - Creating a card reaction touches the card's last_active_at - Reactions are deleted when their parent comment is destroyed - Reactions are deleted when their parent card is destroyed Co-Authored-By: Claude Opus 4.5 * Minor improvements to reactions code - Use size instead of count in boosts partial to avoid extra SQL query on already-loaded collection - Add else clause to reaction_path_prefix_for to raise ArgumentError for unknown reactable types instead of silently returning nil Co-Authored-By: Claude Opus 4.5 * Bump specificity of reaction popup in the card perma --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Andy Smith --- app/assets/stylesheets/card-columns.css | 35 +- app/assets/stylesheets/card-perma.css | 106 ++- app/assets/stylesheets/cards.css | 6 +- app/assets/stylesheets/comments.css | 5 + app/assets/stylesheets/reactions.css | 7 +- app/assets/stylesheets/trays.css | 1 + .../cards/comments/reactions_controller.rb | 26 +- app/controllers/cards/reactions_controller.rb | 49 ++ app/helpers/reactions_helper.rb | 10 + app/models/card.rb | 3 +- app/views/cards/_container.html.erb | 5 +- app/views/cards/comments/_comment.html.erb | 2 +- .../comments/reactions/_reactions.html.erb | 16 - .../reactions/create.turbo_stream.erb | 3 - .../cards/comments/reactions/index.html.erb | 1 - .../comments/reactions/index.json.jbuilder | 1 - app/views/cards/display/_preview.html.erb | 6 +- .../cards/display/_public_preview.html.erb | 1 + .../cards/display/common/_background.html.erb | 16 - .../cards/display/common/_stamp.html.erb | 15 + .../cards/display/perma/_background.html.erb | 2 +- .../cards/display/preview/_boosts.html.erb | 7 + .../cards/display/preview/_comments.html.erb | 4 +- app/views/cards/drafts/_container.html.erb | 2 +- app/views/public/cards/show.html.erb | 3 +- .../comments => }/reactions/_menu.html.erb | 0 .../reactions/_reaction.html.erb | 2 +- .../reactions/_reaction.json.jbuilder | 2 +- app/views/reactions/_reactions.html.erb | 16 + app/views/reactions/create.turbo_stream.erb | 3 + .../reactions/destroy.turbo_stream.erb | 0 app/views/reactions/index.html.erb | 1 + app/views/reactions/index.json.jbuilder | 1 + .../comments => }/reactions/new.html.erb | 12 +- config/routes.rb | 2 + docs/plans/2026-01-21-card-boosts-design.md | 741 ++++++++++++++++++ .../cards/reactions_controller_test.rb | 67 ++ test/fixtures/reactions.yml | 14 + .../card_preview_boost_count_test.rb | 31 + test/models/card_test.rb | 14 + test/models/reaction_test.rb | 33 +- 41 files changed, 1177 insertions(+), 94 deletions(-) create mode 100644 app/controllers/cards/reactions_controller.rb create mode 100644 app/helpers/reactions_helper.rb delete mode 100644 app/views/cards/comments/reactions/_reactions.html.erb delete mode 100644 app/views/cards/comments/reactions/create.turbo_stream.erb delete mode 100644 app/views/cards/comments/reactions/index.html.erb delete mode 100644 app/views/cards/comments/reactions/index.json.jbuilder create mode 100644 app/views/cards/display/common/_stamp.html.erb create mode 100644 app/views/cards/display/preview/_boosts.html.erb rename app/views/{cards/comments => }/reactions/_menu.html.erb (100%) rename app/views/{cards/comments => }/reactions/_reaction.html.erb (93%) rename app/views/{cards/comments => }/reactions/_reaction.json.jbuilder (57%) create mode 100644 app/views/reactions/_reactions.html.erb create mode 100644 app/views/reactions/create.turbo_stream.erb rename app/views/{cards/comments => }/reactions/destroy.turbo_stream.erb (100%) create mode 100644 app/views/reactions/index.html.erb create mode 100644 app/views/reactions/index.json.jbuilder rename app/views/{cards/comments => }/reactions/new.html.erb (65%) create mode 100644 docs/plans/2026-01-21-card-boosts-design.md create mode 100644 test/controllers/cards/reactions_controller_test.rb create mode 100644 test/integration/card_preview_boost_count_test.rb diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 3409b83de..1083c2ffc 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -536,16 +536,33 @@ /* Set lower limit for font size */ font-size: clamp(0.6rem, 0.85cqi, 100px); - .card__comments { - --column-gap: 0.8ch; + .card__counts { + --gap: 0.5ch; + align-items: flex-end; display: flex; - margin-block-end: calc(var(--block-space) * -1.7); - margin-inline-end: calc(var(--card-padding-inline) * -0.5); + flex-shrink: 0; + gap: calc(2 * var(--gap)); + margin-inline: auto calc(var(--card-padding-inline) * -0.5); + padding-inline-start: var(--gap); + } - .icon { - --icon-size: 1.6em; + .card__boosts, + .card__comments { + --icon-size: 1.6em; + align-items: center; + display: flex; + flex-shrink: 0; + font-weight: 600; + gap: var(--gap); + + img { + block-size: var(--icon-size); + inline-size: var(--icon-size); + } + + .icon--comment { color: var(--card-color); } } @@ -584,11 +601,16 @@ .local-time-value { font-weight: inherit; } + + @media (max-width: 639px) { + inline-size: auto; + } } &:has(.card__background img:not([src=""])) { .card__content, .card__meta, + .card__boosts, .card__comments, .card__column-name:not(.card__column-name--current) { opacity: 0; @@ -599,6 +621,7 @@ &:hover { .card__content, .card__footer, + .card__boosts, .card__comments, .card__column-name:not(.card__column-name--current) { opacity: 1; diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index e68706765..b3d37d68a 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -34,6 +34,23 @@ } } + @media (max-width: 799px) { + --half-btn-height: 1.25rem; + --padding-inline: 1.5ch; + + column-gap: 0; + grid-template-areas: + "notch-top notch-top notch-top" + "card card card" + "actions-left notch-bottom actions-right" + "closure-message closure-message closure-message"; + grid-template-columns: 1fr auto 1fr; + inline-size: calc(100% + 2 * var(--padding-inline)); + margin-inline: calc(-1 * var(--padding-inline)); + max-inline-size: none; + position: relative; + } + .card { --card-aspect-ratio: 2 / 0.95; --lexxy-bg-color: var(--card-bg-color); @@ -47,6 +64,8 @@ } .card__body { + position: relative; + @media (max-width: 639px) { flex-direction: column; padding-block-end: calc(var(--card-padding-block) * 1.5); @@ -86,6 +105,11 @@ } } + .card__meta { + grid-area: meta; + margin-inline-end: auto; + } + .card__stages { max-inline-size: 32ch; @@ -119,6 +143,71 @@ } } + .card__footer { + --btn-size: 2.5rem; + + display: flex; + gap: 0.5ch; + inline-size: 100%; + text-align: start; + + /* Switch to grid layout so that the bg zoom button can stay next to the + * meta element, and the reactions can sit below */ + &:has(.reaction) { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + "meta bg-zoom" + "reactions reactions"; + } + + @media (max-width: 639px) { + font-size: var(--text-x-small); + } + } + + .reactions { + --reaction-size: var(--btn-size); + + align-self: flex-end; + display: flex; + gap: 0.5ch; + grid-area: reactions; + margin-inline-start: auto; + + &:has(.reaction) { + --padding: calc(var(--card-padding-block) / 2); + --reaction-size: 1.6875rem; + + margin-block: var(--padding) calc(-1 * var(--padding)); + padding-block-start: var(--padding); + position: relative; + + &:before { + border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%); + content: ""; + inset: 0 calc(-1 * var(--card-padding-inline)) auto; + position: absolute; + } + } + + &:not(:has(.reaction)) { + position: static; + + .reactions__trigger { + --btn-border-color: var(--color-ink-light); + } + } + } + + .reaction__popup.popup { + inline-size: max-content; + } + + .card__zoom-bg-btn { + grid-area: bg-zoom; + } + .bubble { --bubble-number-max: 42px; --bubble-size: 6rem; @@ -132,23 +221,6 @@ inset: calc(var(--bubble-size) / 1.5) 0 auto auto; } } - - @media (max-width: 799px) { - --half-btn-height: 1.5rem; - --padding-inline: 1.5ch; - - column-gap: 0; - grid-template-areas: - "notch-top notch-top notch-top" - "card card card" - "actions-left notch-bottom actions-right" - "closure-message closure-message closure-message"; - grid-template-columns: 1fr auto 1fr; - inline-size: calc(100% + 2 * var(--padding-inline)); - margin-inline: calc(-1 * var(--padding-inline)); - max-inline-size: none; - position: relative; - } } /* Child items diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 3aec6e3b0..0baec71e6 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -252,9 +252,10 @@ padding-block: var(--block-space-half); } - /* Meta + /* Footer /* ------------------------------------------------------------------------ */ + /* Card metadata */ .card__meta { --meta-spacer-block: 0.5ch; --meta-spacer-inline: 0.75ch; @@ -376,11 +377,12 @@ display: flex; flex-direction: column; font-weight: bold; - inset: auto 1ch 1ch auto; + inset: auto 0 -1lh auto; justify-content: center; max-inline-size: 25ch; min-inline-size: 16ch; padding: 1ch; + pointer-events: none; position: absolute; rotate: 5deg; transform-origin: top right; diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 006cbb985..a8605503b 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -80,6 +80,11 @@ display: none !important; } } + + .reactions { + margin-block-start: var(--block-space-half); + margin-inline: calc(var(--column-gap) / -1); + } } .comment__author { diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index d1cd0a03f..64f2a5164 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -10,8 +10,6 @@ flex-wrap: wrap; gap: var(--inline-space-half); inline-size: 100%; - margin-block-start: var(--block-space-half); - margin-inline: calc(var(--column-gap) / -1); &:has([open]) { z-index: var(--z-popup); @@ -34,11 +32,8 @@ .reactions__trigger { --btn-border-color: var(--color-ink-lightest); - background-color: var(--color-ink-lightest); - &:not(:hover) { - opacity: 0.66; - } + background-color: var(--color-ink-lightest); } } } diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 7793b6f2d..0e47857ab 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -440,6 +440,7 @@ .card__meta-text:not(.card__meta-text--updated), .card__stages, .card__steps, + .card__boosts, .card__comments, .card__closed { display: none; diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb index 0f1277a3c..0c822360e 100644 --- a/app/controllers/cards/comments/reactions_controller.rb +++ b/app/controllers/cards/comments/reactions_controller.rb @@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController include CardScoped before_action :set_comment - before_action :set_reaction, only: %i[ destroy ] - before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ] + before_action :set_reactable + + with_options only: :destroy do + before_action :set_reaction + before_action :ensure_permission_to_administer_reaction + end def index + render "reactions/index" end def new + render "reactions/new" end def create - @reaction = @comment.reactions.create!(params.expect(reaction: :content)) + @reaction = @reactable.reactions.create!(params.expect(reaction: :content)) respond_to do |format| - format.turbo_stream + format.turbo_stream { render "reactions/create" } format.json { head :created } end end @@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController @reaction.destroy respond_to do |format| - format.turbo_stream + format.turbo_stream { render "reactions/destroy" } format.json { head :no_content } end end @@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController @comment = @card.comments.find(params[:comment_id]) end - def set_reaction - @reaction = @comment.reactions.find(params[:id]) + def set_reactable + @reactable = @comment end - def ensure_permision_to_administer_reaction + def set_reaction + @reaction = @reactable.reactions.find(params[:id]) + end + + def ensure_permission_to_administer_reaction head :forbidden if Current.user != @reaction.reacter end end diff --git a/app/controllers/cards/reactions_controller.rb b/app/controllers/cards/reactions_controller.rb new file mode 100644 index 000000000..4105a4121 --- /dev/null +++ b/app/controllers/cards/reactions_controller.rb @@ -0,0 +1,49 @@ +class Cards::ReactionsController < ApplicationController + include CardScoped + + before_action :set_reactable + + with_options only: :destroy do + before_action :set_reaction + before_action :ensure_permission_to_administer_reaction + end + + def index + render "reactions/index" + end + + def new + render "reactions/new" + end + + def create + @reaction = @reactable.reactions.create!(params.expect(reaction: :content)) + + respond_to do |format| + format.turbo_stream { render "reactions/create" } + format.json { head :created } + end + end + + def destroy + @reaction.destroy + + respond_to do |format| + format.turbo_stream { render "reactions/destroy" } + format.json { head :no_content } + end + end + + private + def set_reactable + @reactable = @card + end + + def set_reaction + @reaction = @reactable.reactions.find(params[:id]) + end + + def ensure_permission_to_administer_reaction + head :forbidden if Current.user != @reaction.reacter + end +end diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb new file mode 100644 index 000000000..5c47781f6 --- /dev/null +++ b/app/helpers/reactions_helper.rb @@ -0,0 +1,10 @@ +module ReactionsHelper + def reaction_path_prefix_for(reactable) + case reactable + when Card then [ reactable ] + when Comment then [ reactable.card, reactable ] + else + raise ArgumentError, "Unknown reactable type: #{reactable.class}" + end + end +end diff --git a/app/models/card.rb b/app/models/card.rb index 43e2d9361..d422afa85 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -7,6 +7,7 @@ class Card < ApplicationRecord belongs_to :board belongs_to :creator, class_name: "User", default: -> { Current.user } + has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all has_one_attached :image, dependent: :purge_later has_rich_text :description @@ -22,7 +23,7 @@ class Card < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :asc } scope :latest, -> { order last_active_at: :desc, id: :desc } scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) } - scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds } + scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds } scope :indexed_by, ->(index) do case index diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb index de3bc4b27..b6823bd76 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -21,11 +21,14 @@ <% if card.published? %> <%= render "cards/triage/columns", card: card %> <% end %> + + <%= render "cards/display/common/stamp", card: card %> -