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:
@@ -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,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
|
||||
@@ -1,4 +1,6 @@
|
||||
class Access < ApplicationRecord
|
||||
belongs_to :bucket
|
||||
belongs_to :user
|
||||
|
||||
enum :involvement, %i[ access_only watching everything ].index_by(&:itself)
|
||||
end
|
||||
|
||||
@@ -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,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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -27,7 +27,7 @@ class Notifier
|
||||
end
|
||||
|
||||
def recipients
|
||||
bubble.watchers.without(creator)
|
||||
bubble.watchers_and_subscribers.without(creator)
|
||||
end
|
||||
|
||||
def resource
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Notifier::Assigned < Notifier
|
||||
private
|
||||
def recipients
|
||||
event.assignees
|
||||
event.assignees.excluding(bubble.bucket.access_only_users)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class Subscription < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
delegated_type :subscribable, types: Subscribable::TYPES, inverse_of: :subscription
|
||||
end
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
@@ -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
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
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
writebook_kevin:
|
||||
subscribable: writebook (Bucket)
|
||||
user: kevin
|
||||
|
||||
writebook_jz:
|
||||
subscribable: writebook (Bucket)
|
||||
user: jz
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user