Add involvement types to accesses

This provides a way to set the level of involvement that a user has with
a collection, and from which we determine the level of notifications to
send. Users can be access-only, watching, or being notified about
everything.

If you're access-only, you won't get an notifications. If you're
watching, you'll only get notifications for the items you're watching
(which includes the items you've been assigned, have commented on, etc).
If you're set to everything you'll get notifications about all activity
in that collection.

This change replaces our previous concept of subscriptions. Where
previously you'd subscribe to a collection to get notifications in it,
now you'll simply set the notification level on your access.
This commit is contained in:
Kevin McConnell
2025-03-19 11:27:47 +00:00
parent 832134385a
commit 0820badcc3
28 changed files with 531 additions and 543 deletions
@@ -1,10 +1,6 @@
class Bubbles::WatchesController < ApplicationController
include BubbleScoped, BucketScoped
def show
@watchers = @bubble.watchers_and_subscribers.sorted_with_user_first(Current.user)
end
def create
set_watching_and_redirect(true)
end
@@ -0,0 +1,7 @@
class Buckets::InvolvementsController < ApplicationController
include BucketScoped
def update
@bucket.access_for(Current.user).update!(involvement: params[:involvement])
end
end
@@ -1,11 +0,0 @@
class Buckets::SubscriptionsController < ApplicationController
include BucketScoped
def update
if params[:subscribed]
@bucket.subscribe(Current.user)
else
@bucket.unsubscribe(Current.user)
end
end
end
+12
View File
@@ -12,4 +12,16 @@ module AccessesHelper
locals: { selected: selected },
cached: ->(user) { [ user, selected ] }
end
def access_involvement_advance_button(bucket, user)
access = bucket.access_for(user)
button_to access.involvement, bucket_involvement_path(bucket), method: :put, params: { involvement: next_involvement(access.involvement) }
end
private
def next_involvement(involvement)
order = %w[ access_only watching everything ]
order[(order.index(involvement.to_s) + 1) % order.size]
end
end
@@ -0,0 +1,2 @@
module Buckets::InvolvementsHelper
end
+2
View File
@@ -1,4 +1,6 @@
class Access < ApplicationRecord
belongs_to :bucket
belongs_to :user
enum :involvement, %i[ access_only watching everything ].index_by(&:itself)
end
+6 -3
View File
@@ -9,15 +9,18 @@ module Bubble::Watchable
end
def watched_by?(user)
watchers_and_subscribers.include?(user)
watchers_and_subscribers(include_only_watching: true).include?(user)
end
def set_watching(user, watching)
watches.where(user: user).first_or_create.update!(watching: watching)
end
def watchers_and_subscribers
User.where(id: bucket.subscribers.pluck(:id) +
def watchers_and_subscribers(include_only_watching: false)
involvements = include_only_watching ? [ :watching, :everything ] : :everything
subscribers = bucket.users.where(accesses: { involvement: involvements })
User.where(id: subscribers.pluck(:id) +
watches.watching.pluck(:user_id) - watches.not_watching.pluck(:user_id))
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Bucket < ApplicationRecord
include Accessible, Broadcastable, Filterable, Subscribable
include Accessible, Broadcastable, Filterable
belongs_to :account
belongs_to :creator, class_name: "User", default: -> { Current.user }
+5
View File
@@ -20,6 +20,7 @@ module Bucket::Accessible
end
has_many :users, through: :accesses
has_many :access_only_users, -> { merge(Access.access_only) }, through: :accesses, source: :user
scope :all_access, -> { where(all_access: true) }
@@ -27,6 +28,10 @@ module Bucket::Accessible
after_save_commit :grant_access_to_everyone
end
def access_for(user)
accesses.find_by(user: user)
end
private
def grant_access_to_everyone
accesses.grant_to(account.users) if all_access_previously_changed?(to: true)
-22
View File
@@ -1,22 +0,0 @@
module Subscribable
extend ActiveSupport::Concern
TYPES = %w[ Bucket ]
included do
has_many :subscriptions, as: :subscribable, dependent: :destroy
has_many :subscribers, through: :subscriptions, source: :user
end
def subscribe(user)
subscriptions.create_or_find_by!(user: user)
end
def unsubscribe(user)
subscriptions.find_by(user: user)&.destroy!
end
def subscribed_by?(user)
subscriptions.exists?(user: user)
end
end
+1 -1
View File
@@ -27,7 +27,7 @@ class Notifier
end
def recipients
bubble.watchers.without(creator)
bubble.watchers_and_subscribers.without(creator)
end
def resource
+1 -1
View File
@@ -1,6 +1,6 @@
class Notifier::Assigned < Notifier
private
def recipients
event.assignees
event.assignees.excluding(bubble.bucket.access_only_users)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class Notifier::Published < Notifier
private
def recipients
bubble.watchers_and_subscribers.without(creator)
bubble.watchers_and_subscribers(include_only_watching: true).without(creator)
end
end
-5
View File
@@ -1,5 +0,0 @@
class Subscription < ApplicationRecord
belongs_to :user
delegated_type :subscribable, types: Subscribable::TYPES, inverse_of: :subscription
end
-1
View File
@@ -9,7 +9,6 @@ class User < ApplicationRecord
has_many :accesses, dependent: :destroy
has_many :buckets, through: :accesses
has_many :accessible_bubbles, through: :buckets, source: :bubbles
has_many :subscriptions, dependent: :destroy
has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy
@@ -0,0 +1,3 @@
<%= turbo_frame_tag dom_id(@bucket, :involvement) do %>
<%= render "notifications/settings/bucket", bucket: @bucket, user: Current.user %>
<% end %>
@@ -1,18 +1,13 @@
<%= turbo_frame_tag dom_id(bucket, :subscription) do %>
<%= form_with url: bucket_subscriptions_path(bucket), method: :put, data: { controller: "form"} do |form| %>
<div class="flex align-center gap-half pad-inline">
<h2 class="txt-medium overflow-ellipsis">
<%= bucket.name %>
</h2>
<%= turbo_frame_tag dom_id(bucket, :involvement) do %>
<div class="flex align-center gap-half pad-inline">
<h2 class="txt-medium overflow-ellipsis">
<%= bucket.name %>
</h2>
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-subtle-dark); --border-style: dashed" aria-hidden="true">
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-subtle-dark); --border-style: dashed" aria-hidden="true">
<label class="switch flex-item-no-shrink">
<%= form.checkbox :subscribed, checked: bucket.subscribed_by?(user), id: dom_id(bucket, :subscribed),
class: "switch__input", include_hidden: false, data: { action: "form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Enable notifications for this bucket</span>
</label>
</div>
<% end %>
<label class="switch flex-item-no-shrink">
<%= access_involvement_advance_button(bucket, user) %>
</label>
</div>
<% end %>
+1
View File
@@ -36,6 +36,7 @@ Rails.application.routes.draw do
scope module: :buckets do
resource :subscriptions
resource :workflow, only: :update
resource :involvement
end
resources :bubbles do
@@ -0,0 +1,7 @@
class AddInvolvementToAccesses < ActiveRecord::Migration[8.1]
def change
change_table :accesses do |t|
t.string :involvement, null: false, default: "watching"
end
end
end
@@ -0,0 +1,14 @@
class DropSubscriptions < ActiveRecord::Migration[8.1]
def change
execute "
update accesses set involvement = 'access_only'
"
execute "
update accesses set involvement = 'watching'
from (select user_id, subscribable_id as bucket_id from subscriptions) as subscriptions
where subscriptions.user_id = accesses.user_id and subscriptions.bucket_id = accesses.bucket_id
"
drop_table :subscriptions
end
end
Generated
+10 -13
View File
@@ -10,10 +10,11 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_03_27_191456) do
ActiveRecord::Schema[8.1].define(version: 2025_04_03_094604) do
create_table "accesses", force: :cascade do |t|
t.integer "bucket_id", null: false
t.datetime "created_at", null: false
t.string "involvement", default: "watching", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["bucket_id", "user_id"], name: "index_accesses_on_bucket_id_and_user_id", unique: true
@@ -94,6 +95,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_03_27_191456) do
t.index ["bubble_id"], name: "index_assignments_on_bubble_id"
end
create_table "bubble_engagements", force: :cascade do |t|
t.integer "bubble_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["bubble_id"], name: "index_bubble_engagements_on_bubble_id"
end
create_table "bubbles", force: :cascade do |t|
t.float "activity_score", default: 0.0, null: false
t.datetime "activity_score_at"
@@ -222,6 +230,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_03_27_191456) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["bubble_id", "user_id"], name: "index_pins_on_bubble_id_and_user_id", unique: true
t.index ["bubble_id"], name: "index_pins_on_bubble_id"
t.index ["user_id"], name: "index_pins_on_user_id"
end
@@ -254,17 +263,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_03_27_191456) do
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "subscriptions", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "subscribable_id", null: false
t.string "subscribable_type", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["subscribable_type", "subscribable_id", "user_id"], name: "idx_on_subscribable_type_subscribable_id_user_id_81936d569b", unique: true
t.index ["subscribable_type", "subscribable_id"], name: "index_subscriptions_on_subscribable"
t.index ["user_id"], name: "index_subscriptions_on_user_id"
end
create_table "taggings", force: :cascade do |t|
t.integer "bubble_id", null: false
t.datetime "created_at", null: false
@@ -337,7 +335,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_03_27_191456) do
add_foreign_key "pops", "bubbles"
add_foreign_key "pops", "users"
add_foreign_key "sessions", "users"
add_foreign_key "subscriptions", "users"
add_foreign_key "taggings", "bubbles"
add_foreign_key "taggings", "tags"
add_foreign_key "users", "accounts"
+404 -423
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
require "test_helper"
class Buckets::InvolvementsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "update" do
bucket = buckets(:writebook)
bucket.access_for(users(:kevin)).access_only!
assert_changes -> { bucket.access_for(users(:kevin)).involvement }, from: "access_only", to: "watching" do
put bucket_involvement_url(bucket, involvement: "watching")
end
assert_response :success
end
end
@@ -1,25 +0,0 @@
require "test_helper"
class Buckets::SubscriptionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :david
end
test "subscribing to a bucket" do
assert_changes -> { buckets(:writebook).subscribed_by?(users(:david)) }, from: false, to: true do
put bucket_subscriptions_url(buckets(:writebook)), params: { subscribed: "1" }
end
assert_response :success
end
test "unsubscribing from a bucket" do
buckets(:writebook).subscribe(users(:david))
assert_changes -> { buckets(:writebook).subscribed_by?(users(:david)) }, from: true, to: false do
put bucket_subscriptions_url(buckets(:writebook))
end
assert_response :success
end
end
-7
View File
@@ -1,7 +0,0 @@
writebook_kevin:
subscribable: writebook (Bucket)
user: kevin
writebook_jz:
subscribable: writebook (Bucket)
user: jz
+1 -1
View File
@@ -2,7 +2,7 @@ require "test_helper"
class Bubble::CommentableTest < ActiveSupport::TestCase
test "creating a comment on a bubble makes the creator watch the bubble" do
buckets(:writebook).subscriptions.destroy_all
buckets(:writebook).access_for(users(:kevin)).access_only!
assert_not bubbles(:text).watched_by?(users(:kevin))
with_current_user(:kevin) do
+5 -8
View File
@@ -3,7 +3,7 @@ require "test_helper"
class Bubble::WatchableTest < ActiveSupport::TestCase
setup do
Watch.destroy_all
Subscription.destroy_all
Access.all.update!(involvement: :access_only)
end
test "watched_by?" do
@@ -16,9 +16,8 @@ class Bubble::WatchableTest < ActiveSupport::TestCase
assert_not bubbles(:logo).watched_by?(users(:kevin))
end
test "watched_by? when subscribed to the bucket" do
buckets(:writebook).subscribe(users(:kevin))
test "watched_by? when notifications are set on the bucket" do
buckets(:writebook).access_for(users(:kevin)).watching!
assert bubbles(:text).watched_by?(users(:kevin))
bubbles(:logo).set_watching(users(:kevin), false)
@@ -26,16 +25,14 @@ class Bubble::WatchableTest < ActiveSupport::TestCase
end
test "bubbles are initially watched by their creator" do
buckets(:writebook).unsubscribe(users(:kevin))
bubble = buckets(:writebook).bubbles.create!(creator: users(:kevin))
assert bubble.watched_by?(users(:kevin))
end
test "watchers_and_subscribers" do
buckets(:writebook).subscribe(users(:kevin))
buckets(:writebook).subscribe(users(:jz))
buckets(:writebook).access_for(users(:kevin)).watching!
buckets(:writebook).access_for(users(:jz)).everything!
bubbles(:logo).set_watching(users(:kevin), true)
bubbles(:logo).set_watching(users(:jz), false)
+20 -1
View File
@@ -28,6 +28,14 @@ class NotifierTest < ActiveSupport::TestCase
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "does not create a notification for access-only users" do
buckets(:writebook).access_for(users(:kevin)).access_only!
notifications = Notifier.for(events(:layout_commented)).generate
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "the published event creates notifications for subscribers as well as watchers" do
notifications = Notifier.for(events(:logo_published)).generate
@@ -40,9 +48,20 @@ class NotifierTest < ActiveSupport::TestCase
assert_equal bubbles(:logo), Notification.last.resource
end
test "for assignment events only create a notification for the assignee" do
test "assignment events only create a notification for the assignee" do
buckets(:writebook).access_for(users(:jz)).watching!
buckets(:writebook).access_for(users(:kevin)).everything!
notifications = Notifier.for(events(:logo_assignment_jz)).generate
assert_equal [ users(:jz) ], notifications.map(&:user)
end
test "assignment events do not notify users who are access-only for the collection" do
buckets(:writebook).access_for(users(:jz)).access_only!
notifications = Notifier.for(events(:logo_assignment_jz)).generate
assert_empty notifications
end
end