diff --git a/app/jobs/create_push_notifications_job.rb b/app/jobs/create_push_notifications_job.rb new file mode 100644 index 000000000..3e7ccff3b --- /dev/null +++ b/app/jobs/create_push_notifications_job.rb @@ -0,0 +1,11 @@ +class CreatePushNotificationsJob < ActiveJob::Base + queue_as :default + + def perform(event_id:) + event = ::Event.find_by_id(event_id) + + raise 'A valid Event must be provided' unless event + + ::PushNotifications::Creator.new(event: event).create! + end +end diff --git a/app/models/push_notification.rb b/app/models/push_notification.rb new file mode 100644 index 000000000..5aff8621a --- /dev/null +++ b/app/models/push_notification.rb @@ -0,0 +1,6 @@ +class PushNotification < ActiveRecord::Base + belongs_to :event, foreign_key: 'event_id' + belongs_to :device_token, foreign_key: 'device_token_id' + + validates :event, :device_token, presence: true +end diff --git a/app/models/push_notifications/post_notification.rb b/app/models/push_notifications/post_notification.rb deleted file mode 100644 index 9a48f37c2..000000000 --- a/app/models/push_notifications/post_notification.rb +++ /dev/null @@ -1,11 +0,0 @@ -module PushNotifications - class PostNotification - def title - "Notification title" - end - - def body - "Notification body" - end - end -end diff --git a/app/services/persister/post_persister.rb b/app/services/persister/post_persister.rb index 0c0bb8004..d4f0a027b 100644 --- a/app/services/persister/post_persister.rb +++ b/app/services/persister/post_persister.rb @@ -10,6 +10,7 @@ def save ::ActiveRecord::Base.transaction do post.save! create_save_event! + enqueue_push_notification_job! post end rescue ActiveRecord::RecordInvalid => _exception @@ -20,6 +21,7 @@ def update_attributes(params) ::ActiveRecord::Base.transaction do post.update_attributes!(params) create_update_event! + enqueue_push_notification_job! post end rescue ActiveRecord::RecordInvalid => _exception @@ -28,12 +30,18 @@ def update_attributes(params) private + attr_accessor :event + def create_save_event! - ::Event.create! action: :created, post: post + @event = ::Event.create! action: :created, post: post end def create_update_event! - ::Event.create! action: :updated, post: post + @event = ::Event.create! action: :updated, post: post + end + + def enqueue_push_notification_job! + CreatePushNotificationsJob.perform_later(event_id: event.id) end end end diff --git a/app/services/push_notifications/creator.rb b/app/services/push_notifications/creator.rb new file mode 100644 index 000000000..9096cbdd8 --- /dev/null +++ b/app/services/push_notifications/creator.rb @@ -0,0 +1,22 @@ +module PushNotifications + class Creator + # Given an Event it will create as many PushNotification resources + # necessary as the resource associated to the Event will require. + # + # @param [Hash] event: + def initialize(event:) + @event = event + end + + def create! + event_notifier = EventNotifierFactory.new(event: event).build + event_notifier.device_tokens.each do |device_token| + PushNotification.create!(event: event, device_token: device_token) + end + end + + private + + attr_accessor :event + end +end diff --git a/app/services/push_notifications/event_notifier/post.rb b/app/services/push_notifications/event_notifier/post.rb new file mode 100644 index 000000000..6e5592d70 --- /dev/null +++ b/app/services/push_notifications/event_notifier/post.rb @@ -0,0 +1,26 @@ +module PushNotifications + module EventNotifier + class Post + def initialize(event:) + @event = event + @post = event.post + end + + # Conditions for Post: + # + # We need to notify all the users that: + # - are members of the Post's organization + # - have a DeviceToken associated + # + # @return [] + def device_tokens + organization = post.organization + DeviceToken.where(user_id: organization.user_ids) + end + + private + + attr_accessor :event, :post + end + end +end diff --git a/app/services/push_notifications/event_notifier_factory.rb b/app/services/push_notifications/event_notifier_factory.rb new file mode 100644 index 000000000..092583084 --- /dev/null +++ b/app/services/push_notifications/event_notifier_factory.rb @@ -0,0 +1,17 @@ +module PushNotifications + class EventNotifierFactory + def initialize(event:) + @event = event + end + + def build + return EventNotifier::Post.new(event: event) if event.post_id + + raise 'The resource associated to the Event is not supported' + end + + private + + attr_accessor :event + end +end diff --git a/config/environments/test.rb b/config/environments/test.rb index 1037818bc..5dfdff1c1 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -44,4 +44,7 @@ # Avoid seeing all that stuff in tests config.log_level = :warn + + # ActiveJob configuration + config.active_job.queue_adapter = :test end diff --git a/db/migrate/20180524143938_create_push_notifications.rb b/db/migrate/20180524143938_create_push_notifications.rb new file mode 100644 index 000000000..3960cfb8f --- /dev/null +++ b/db/migrate/20180524143938_create_push_notifications.rb @@ -0,0 +1,14 @@ +class CreatePushNotifications < ActiveRecord::Migration + def change + create_table :push_notifications do |t| + t.references :event, null: false + t.references :device_token, null: false + t.datetime :processed_at + + t.timestamps null: false + end + + add_foreign_key :push_notifications, :events + add_foreign_key :push_notifications, :device_tokens + end +end diff --git a/db/migrate/20180529144243_change_index_on_events.rb b/db/migrate/20180529144243_change_index_on_events.rb new file mode 100644 index 000000000..f0753c69b --- /dev/null +++ b/db/migrate/20180529144243_change_index_on_events.rb @@ -0,0 +1,11 @@ +class ChangeIndexOnEvents < ActiveRecord::Migration + def change + remove_index :events, :post_id + remove_index :events, :member_id + remove_index :events, :transfer_id + + add_index :events, :post_id, where: 'post_id IS NOT NULL' + add_index :events, :member_id, where: 'member_id IS NOT NULL' + add_index :events, :transfer_id, where: 'transfer_id IS NOT NULL' + end +end diff --git a/db/schema.rb b/db/schema.rb index 30b67f3d9..1144dc53a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180525141138) do +ActiveRecord::Schema.define(version: 20180529144243) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -85,9 +85,9 @@ t.datetime "updated_at" end - add_index "events", ["member_id"], name: "index_events_on_member_id", unique: true, where: "(member_id IS NOT NULL)", using: :btree - add_index "events", ["post_id"], name: "index_events_on_post_id", unique: true, where: "(post_id IS NOT NULL)", using: :btree - add_index "events", ["transfer_id"], name: "index_events_on_transfer_id", unique: true, where: "(transfer_id IS NOT NULL)", using: :btree + add_index "events", ["member_id"], name: "index_events_on_member_id", where: "(member_id IS NOT NULL)", using: :btree + add_index "events", ["post_id"], name: "index_events_on_post_id", where: "(post_id IS NOT NULL)", using: :btree + add_index "events", ["transfer_id"], name: "index_events_on_transfer_id", where: "(transfer_id IS NOT NULL)", using: :btree create_table "members", force: :cascade do |t| t.integer "user_id" @@ -158,6 +158,14 @@ add_index "posts", ["tags"], name: "index_posts_on_tags", using: :gin add_index "posts", ["user_id"], name: "index_posts_on_user_id", using: :btree + create_table "push_notifications", force: :cascade do |t| + t.integer "event_id", null: false + t.integer "device_token_id", null: false + t.datetime "processed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "transfers", force: :cascade do |t| t.integer "post_id" t.text "reason" @@ -215,4 +223,6 @@ add_foreign_key "events", "members", name: "events_member_id_fkey" add_foreign_key "events", "posts", name: "events_post_id_fkey" add_foreign_key "events", "transfers", name: "events_transfer_id_fkey" + add_foreign_key "push_notifications", "device_tokens" + add_foreign_key "push_notifications", "events" end diff --git a/spec/fabricators/device_token_fabricator.rb b/spec/fabricators/device_token_fabricator.rb new file mode 100644 index 000000000..64f2a980f --- /dev/null +++ b/spec/fabricators/device_token_fabricator.rb @@ -0,0 +1,2 @@ +Fabricator(:device_token) do +end diff --git a/spec/fabricators/event_fabricator.rb b/spec/fabricators/event_fabricator.rb new file mode 100644 index 000000000..f02d4de81 --- /dev/null +++ b/spec/fabricators/event_fabricator.rb @@ -0,0 +1,2 @@ +Fabricator(:event) do +end diff --git a/spec/jobs/create_push_notifications_job_spec.rb b/spec/jobs/create_push_notifications_job_spec.rb new file mode 100644 index 000000000..34bcc0414 --- /dev/null +++ b/spec/jobs/create_push_notifications_job_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +RSpec.describe CreatePushNotificationsJob, type: :job do + describe '#perform' do + context 'with an Event that doesn\'t exist' do + let(:event_id) { nil } + + it 'raises an error' do + expect { + described_class.new.perform(event_id: event_id) + }.to raise_error 'A valid Event must be provided' + end + end + + context 'with an Event that does exist' do + let(:post) { Fabricate(:post) } + let(:event) { Fabricate(:event, post: post, action: :created) } + let(:event_id) { event.id } + + it 'calls the PushNotification creator' do + creator = instance_double(::PushNotifications::Creator) + expect(::PushNotifications::Creator).to receive(:new) + .with(event: event) + .and_return(creator) + expect(creator).to receive(:create!) + + described_class.new.perform(event_id: event_id) + end + end + end +end diff --git a/spec/models/push_notification_spec.rb b/spec/models/push_notification_spec.rb new file mode 100644 index 000000000..f17261023 --- /dev/null +++ b/spec/models/push_notification_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +RSpec.describe PushNotification do + describe 'Validations' do + it { is_expected.to validate_presence_of(:event) } + it { is_expected.to validate_presence_of(:device_token) } + end + + describe 'Associations' do + it { is_expected.to belong_to(:event).with_foreign_key('event_id') } + it { is_expected.to belong_to(:device_token).with_foreign_key('device_token_id') } + + it { is_expected.to have_db_column(:event_id) } + it { is_expected.to have_db_column(:device_token_id) } + end +end diff --git a/spec/services/persister/post_persister_spec.rb b/spec/services/persister/post_persister_spec.rb index 6ae8139b6..16058b963 100644 --- a/spec/services/persister/post_persister_spec.rb +++ b/spec/services/persister/post_persister_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Persister::PostPersister do +RSpec.describe Persister::PostPersister do let(:organization) { Fabricate(:organization) } let(:user) { Fabricate(:user) } let(:category) { Fabricate(:category) } @@ -14,30 +14,57 @@ ) end let(:persister) { ::Persister::PostPersister.new(post) } + let(:event) { Fabricate.build(:event, id: 27) } describe '#save' do - before { persister.save } - it 'saves the post' do + persister.save + expect(post).to be_persisted end - # TODO: write better expectation it 'creates an event' do - expect(Event.where(post_id: post.id).first.action).to eq('created') + expect(::Event).to receive(:create!).with(action: :created, post: post).and_return(event) + + persister.save + end + + context 'background job' do + before do + allow(::Event).to receive(:create!).and_return(event) + end + + it 'enqueues a CreatePushNotificationsJob background job' do + expect { + persister.save + }.to enqueue_job(CreatePushNotificationsJob).with(event_id: 27) + end end end describe '#update_attributes' do - before { persister.update_attributes(title: 'New title') } - it 'updates the resource attributes' do + persister.update_attributes(title: 'New title') + expect(post.title).to eq('New title') end - # TODO: write better expectation it 'creates an event' do - expect(Event.where(post_id: post.id).first.action).to eq('updated') + expect(::Event).to receive(:create!).with(action: :updated, post: post).and_return(event) + + persister.update_attributes(title: 'New title') + end + + context 'background job' do + before do + allow(::Event).to receive(:create!).and_return(event) + end + + it 'enqueues a CreatePushNotificationsJob background job' do + expect { + persister.update_attributes(title: 'New title') + }.to enqueue_job(CreatePushNotificationsJob).with(event_id: 27) + end end end end diff --git a/spec/services/push_notifications/creator_spec.rb b/spec/services/push_notifications/creator_spec.rb new file mode 100644 index 000000000..4b8df3f17 --- /dev/null +++ b/spec/services/push_notifications/creator_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +RSpec.describe PushNotifications::Creator do + let(:user) { Fabricate(:user) } + let!(:device_token) { Fabricate(:device_token, user: user, token: 'aloha') } + let(:organization) { Fabricate(:organization) } + let(:post) { Fabricate(:post, organization: organization, user: user) } + let(:event) { Fabricate.build(:event, post: post, action: :created) } + let(:creator) { described_class.new(event: event) } + + before do + organization.members.create(user: user) + end + + describe '#create!' do + it 'creates as many PushNotification resources as needed' do + expect(PushNotification).to receive(:create!).with( + event: event, + device_token: device_token + ).once + + creator.create! + end + end +end diff --git a/spec/services/push_notifications/event_notifier/post_spec.rb b/spec/services/push_notifications/event_notifier/post_spec.rb new file mode 100644 index 000000000..8a47f81c1 --- /dev/null +++ b/spec/services/push_notifications/event_notifier/post_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +RSpec.describe ::PushNotifications::EventNotifier::Post do + let(:user) { Fabricate.build(:user) } + let(:organization) { Fabricate(:organization) } + let(:post) { Fabricate(:post, organization: organization, user: user) } + let(:event) { Fabricate.build(:event, post: post, action: :created) } + + describe '#users' do + context 'when a user pertains to the Post\'s organization' do + before { organization.members.create(user: user) } + + context 'and the user has a DeviceToken associated' do + let!(:device_token) { Fabricate(:device_token, user: user, token: 'aloha') } + + it 'returns the device token associated with the user' do + expect(described_class.new(event: event).device_tokens).to eq([device_token]) + end + end + + context 'but the user has no DeviceToken associated' do + it 'doesn\'t return the user' do + expect(described_class.new(event: event).device_tokens).to eq([]) + end + end + end + + context 'when a user doesn\'t pertain to the Post\'s organization' do + let(:other_user) { Fabricate(:user) } + let!(:device_token) { Fabricate(:device_token, user: other_user, token: 'WAT') } + + it 'doesn\'t return the user' do + expect(described_class.new(event: event).device_tokens).to_not include(device_token) + end + end + end +end diff --git a/spec/services/push_notifications/event_notifier_factory_spec.rb b/spec/services/push_notifications/event_notifier_factory_spec.rb new file mode 100644 index 000000000..6469c12ef --- /dev/null +++ b/spec/services/push_notifications/event_notifier_factory_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +RSpec.describe PushNotifications::EventNotifierFactory do + describe '#build' do + let(:user) { Fabricate.build(:user) } + let(:organization) { Fabricate(:organization) } + let(:factory) { described_class.new(event: event) } + + context 'when the given Event is associated to a Post' do + let(:post) { Fabricate(:post, organization: organization, user: user) } + let(:event) { Fabricate.build(:event, post: post, action: :created) } + + it 'returns a Post notifier' do + notifier = instance_double(::PushNotifications::EventNotifier::Post) + allow(::PushNotifications::EventNotifier::Post).to receive(:new).and_return(notifier) + + expect(factory.build).to be(notifier) + end + end + + context 'when the given Event is associated to a resource not supported' do + let(:event) { Fabricate.build(:event, action: :created) } + + it 'raises an error' do + expect { + factory.build + }.to raise_error('The resource associated to the Event is not supported') + end + end + end +end