├── .gitignore ├── .rspec ├── .rubocop-https---relaxed-ruby-style-rubocop-yml ├── .rubocop.yml ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── app └── models │ └── hertz │ ├── delivery.rb │ └── notification.rb ├── bin └── rails ├── config.ru ├── config └── routes.rb ├── db └── migrate │ ├── 20160415174901_create_hertz_notifications.rb │ ├── 20160627084018_create_hertz_notification_deliveries.rb │ ├── 20160628084342_rename_notification_deliveries_to_deliveries.rb │ └── 20200112143142_convert_notification_meta_to_jsonb.rb ├── hertz.gemspec ├── lib ├── generators │ └── hertz │ │ ├── install_generator.rb │ │ └── templates │ │ └── initializer.rb ├── hertz.rb └── hertz │ ├── engine.rb │ ├── notifiable.rb │ ├── notification_deliverer.rb │ └── version.rb └── spec ├── factories ├── hertz │ ├── deliveries.rb │ └── notifications.rb └── users.rb ├── hertz ├── hertz_spec.rb ├── notifiable_spec.rb └── notification_deliverer_spec.rb ├── internal ├── app │ ├── assets │ │ └── config │ │ │ └── manifest.js │ └── models │ │ ├── test_notification.rb │ │ └── user.rb ├── config │ ├── database.yml │ └── routes.rb ├── db │ └── schema.rb ├── log │ └── .gitignore └── public │ └── favicon.ico ├── models └── hertz │ └── notification_spec.rb ├── spec_helper.rb └── support ├── database_cleaner.rb ├── factory_bot.rb └── faker.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /log/*.log 3 | /pkg/ 4 | /spec/dummy/log/*.log 5 | /spec/dummy/tmp/ 6 | /spec/dummy/.sass-cache 7 | /spec/dummy/config/database.yml 8 | /spec/examples.txt 9 | /coverage 10 | .ruby-version 11 | Gemfile.lock 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --format Fuubar 4 | -------------------------------------------------------------------------------- /.rubocop-https---relaxed-ruby-style-rubocop-yml: -------------------------------------------------------------------------------- 1 | # Relaxed.Ruby.Style 2 | ## Version 2.4 3 | 4 | Style/Alias: 5 | Enabled: false 6 | StyleGuide: https://relaxed.ruby.style/#stylealias 7 | 8 | Style/AsciiComments: 9 | Enabled: false 10 | StyleGuide: https://relaxed.ruby.style/#styleasciicomments 11 | 12 | Style/BeginBlock: 13 | Enabled: false 14 | StyleGuide: https://relaxed.ruby.style/#stylebeginblock 15 | 16 | Style/BlockDelimiters: 17 | Enabled: false 18 | StyleGuide: https://relaxed.ruby.style/#styleblockdelimiters 19 | 20 | Style/CommentAnnotation: 21 | Enabled: false 22 | StyleGuide: https://relaxed.ruby.style/#stylecommentannotation 23 | 24 | Style/Documentation: 25 | Enabled: false 26 | StyleGuide: https://relaxed.ruby.style/#styledocumentation 27 | 28 | Layout/DotPosition: 29 | Enabled: false 30 | StyleGuide: https://relaxed.ruby.style/#layoutdotposition 31 | 32 | Style/DoubleNegation: 33 | Enabled: false 34 | StyleGuide: https://relaxed.ruby.style/#styledoublenegation 35 | 36 | Style/EndBlock: 37 | Enabled: false 38 | StyleGuide: https://relaxed.ruby.style/#styleendblock 39 | 40 | Style/FormatString: 41 | Enabled: false 42 | StyleGuide: https://relaxed.ruby.style/#styleformatstring 43 | 44 | Style/IfUnlessModifier: 45 | Enabled: false 46 | StyleGuide: https://relaxed.ruby.style/#styleifunlessmodifier 47 | 48 | Style/Lambda: 49 | Enabled: false 50 | StyleGuide: https://relaxed.ruby.style/#stylelambda 51 | 52 | Style/ModuleFunction: 53 | Enabled: false 54 | StyleGuide: https://relaxed.ruby.style/#stylemodulefunction 55 | 56 | Style/MultilineBlockChain: 57 | Enabled: false 58 | StyleGuide: https://relaxed.ruby.style/#stylemultilineblockchain 59 | 60 | Style/NegatedIf: 61 | Enabled: false 62 | StyleGuide: https://relaxed.ruby.style/#stylenegatedif 63 | 64 | Style/NegatedWhile: 65 | Enabled: false 66 | StyleGuide: https://relaxed.ruby.style/#stylenegatedwhile 67 | 68 | Style/NumericPredicate: 69 | Enabled: false 70 | StyleGuide: https://relaxed.ruby.style/#stylenumericpredicate 71 | 72 | Style/ParallelAssignment: 73 | Enabled: false 74 | StyleGuide: https://relaxed.ruby.style/#styleparallelassignment 75 | 76 | Style/PercentLiteralDelimiters: 77 | Enabled: false 78 | StyleGuide: https://relaxed.ruby.style/#stylepercentliteraldelimiters 79 | 80 | Style/PerlBackrefs: 81 | Enabled: false 82 | StyleGuide: https://relaxed.ruby.style/#styleperlbackrefs 83 | 84 | Style/Semicolon: 85 | Enabled: false 86 | StyleGuide: https://relaxed.ruby.style/#stylesemicolon 87 | 88 | Style/SignalException: 89 | Enabled: false 90 | StyleGuide: https://relaxed.ruby.style/#stylesignalexception 91 | 92 | Style/SingleLineBlockParams: 93 | Enabled: false 94 | StyleGuide: https://relaxed.ruby.style/#stylesinglelineblockparams 95 | 96 | Style/SingleLineMethods: 97 | Enabled: false 98 | StyleGuide: https://relaxed.ruby.style/#stylesinglelinemethods 99 | 100 | Layout/SpaceBeforeBlockBraces: 101 | Enabled: false 102 | StyleGuide: https://relaxed.ruby.style/#layoutspacebeforeblockbraces 103 | 104 | Layout/SpaceInsideParens: 105 | Enabled: false 106 | StyleGuide: https://relaxed.ruby.style/#layoutspaceinsideparens 107 | 108 | Style/SpecialGlobalVars: 109 | Enabled: false 110 | StyleGuide: https://relaxed.ruby.style/#stylespecialglobalvars 111 | 112 | Style/StringLiterals: 113 | Enabled: false 114 | StyleGuide: https://relaxed.ruby.style/#stylestringliterals 115 | 116 | Style/TrailingCommaInArguments: 117 | Enabled: false 118 | StyleGuide: https://relaxed.ruby.style/#styletrailingcommainarguments 119 | 120 | Style/TrailingCommaInArrayLiteral: 121 | Enabled: false 122 | StyleGuide: https://relaxed.ruby.style/#styletrailingcommainarrayliteral 123 | 124 | Style/TrailingCommaInHashLiteral: 125 | Enabled: false 126 | StyleGuide: https://relaxed.ruby.style/#styletrailingcommainhashliteral 127 | 128 | Style/SymbolArray: 129 | Enabled: false 130 | StyleGuide: http://relaxed.ruby.style/#stylesymbolarray 131 | 132 | Style/WhileUntilModifier: 133 | Enabled: false 134 | StyleGuide: https://relaxed.ruby.style/#stylewhileuntilmodifier 135 | 136 | Style/WordArray: 137 | Enabled: false 138 | StyleGuide: https://relaxed.ruby.style/#stylewordarray 139 | 140 | Lint/AmbiguousRegexpLiteral: 141 | Enabled: false 142 | StyleGuide: https://relaxed.ruby.style/#lintambiguousregexpliteral 143 | 144 | Lint/AssignmentInCondition: 145 | Enabled: false 146 | StyleGuide: https://relaxed.ruby.style/#lintassignmentincondition 147 | 148 | Metrics/AbcSize: 149 | Enabled: false 150 | 151 | Metrics/BlockNesting: 152 | Enabled: false 153 | 154 | Metrics/ClassLength: 155 | Enabled: false 156 | 157 | Metrics/ModuleLength: 158 | Enabled: false 159 | 160 | Metrics/CyclomaticComplexity: 161 | Enabled: false 162 | 163 | Metrics/LineLength: 164 | Enabled: false 165 | 166 | Metrics/MethodLength: 167 | Enabled: false 168 | 169 | Metrics/ParameterLists: 170 | Enabled: false 171 | 172 | Metrics/PerceivedComplexity: 173 | Enabled: false 174 | 175 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - https://relaxed.ruby.style/rubocop.yml 3 | 4 | AllCops: 5 | TargetRubyVersion: 2.4 6 | 7 | Metrics/BlockLength: 8 | Enabled: false 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | before_install: gem install bundler 4 | 5 | services: 6 | - postgresql 7 | 8 | before_script: 9 | - psql -c 'create database travis_ci_test;' -U postgres 10 | 11 | matrix: 12 | include: 13 | - rvm: 2.4 14 | env: RAILS_VERSION='~> 5.2.0' 15 | - rvm: 2.5 16 | env: RAILS_VERSION='~> 5.2.0' 17 | - rvm: 2.5 18 | env: RAILS_VERSION='~> 6.0.0' 19 | - rvm: 2.6 20 | env: RAILS_VERSION='~> 5.2.0' 21 | - rvm: 2.6 22 | env: RAILS_VERSION='~> 6.0.0' 23 | - rvm: 2.7 24 | env: RAILS_VERSION='~> 5.2.0' 25 | - rvm: 2.7 26 | env: RAILS_VERSION='~> 6.0.0' 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project 6 | adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [2.1.0] 11 | 12 | ### Added 13 | 14 | - Added support for Rails 6! 15 | 16 | ### Changed 17 | 18 | - Rails 5.2.0 is now the minimum required version 19 | - Adopted jsonb for notifications meta 20 | 21 | ## [2.0.0] 22 | 23 | ### Changed 24 | 25 | - Look for couriers in the root `Hertz` namespace 26 | 27 | ## [1.0.3] 28 | 29 | **This is when we actually started with SemVer.** 30 | 31 | ### Changed 32 | 33 | - Updated and linted codebase 34 | 35 | ## [1.0.2] 36 | 37 | ### Added 38 | 39 | - Implemented new `#notify` syntax 40 | 41 | ## [1.0.1] 42 | 43 | ### Added 44 | 45 | - Implemented Deliveries API 46 | - Implemented `email` and `sms` couriers 47 | 48 | ### Fixed 49 | 50 | - Fixed notifications being delivered before DB commit 51 | 52 | ## [1.0.0] 53 | 54 | First stable release. 55 | 56 | ## [0.1.0] 57 | 58 | First public release. 59 | 60 | [Unreleased]: https://github.com/aldesantis/hertz/compare/v2.1.0...HEAD 61 | [2.1.0]: https://github.com/aldesantis/hertz/compare/v2.0.0...v2.1.0 62 | [2.0.0]: https://github.com/aldesantis/hertz/compare/v1.0.3...v2.0.0 63 | [1.0.3]: https://github.com/aldesantis/hertz/compare/v1.0.2...v1.0.3 64 | [1.0.2]: https://github.com/aldesantis/hertz/compare/v1.0.1...v1.0.2 65 | [1.0.1]: https://github.com/aldesantis/hertz/compare/v1.0.0...v1.0.1 66 | [1.0.0]: https://github.com/aldesantis/hertz/compare/v0.1.0...v1.0.0 67 | [0.1.0]: https://github.com/aldesantis/hertz/tree/v0.1.0 68 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Declare your gem's dependencies in hertz.gemspec. 6 | # Bundler will treat runtime dependencies like base dependencies, and 7 | # development dependencies will be added by default to the :development group. 8 | gemspec 9 | 10 | gem 'rails', ENV.fetch('RAILS_VERSION', '~> 6.0.0') 11 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Alessandro Desantis 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hertz 2 | 3 | [![Build Status](https://travis-ci.org/aldesantis/hertz.svg?branch=master)](https://travis-ci.org/aldesantis/hertz) 4 | [![Coverage Status](https://coveralls.io/repos/github/aldesantis/hertz/badge.svg?branch=master)](https://coveralls.io/github/aldesantis/hertz?branch=master) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/84d43f19a0ec0bf62ede/maintainability)](https://codeclimate.com/github/aldesantis/hertz/maintainability) 6 | 7 | Hertz is a Ruby on Rails engine for sending in-app notifications to your users. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'hertz' 15 | ``` 16 | 17 | And then execute: 18 | 19 | ```console 20 | $ bundle 21 | ``` 22 | 23 | Or install it yourself as: 24 | 25 | ```console 26 | $ gem install hertz 27 | ``` 28 | 29 | Then, run the installer generator: 30 | 31 | ```console 32 | $ rails g hertz:install 33 | $ rake db:migrate 34 | ``` 35 | 36 | Finally, add the following to the model that will receive the notifications (e.g. `User`): 37 | 38 | ```ruby 39 | class User < ActiveRecord::Base 40 | include Hertz::Notifiable 41 | end 42 | ``` 43 | 44 | ## Usage 45 | 46 | ### Using couriers 47 | 48 | Couriers are what Hertz uses to deliver notifications to your users. For instance, you might have a 49 | courier for delivering notifications by SMS and another one for delivering them by email. 50 | 51 | Creating a new courier in Hertz is easy: 52 | 53 | ```ruby 54 | module Hertz 55 | class Sms 56 | def self.deliver_notification(notification) 57 | # ... 58 | end 59 | end 60 | end 61 | ``` 62 | 63 | ### Creating new notification types 64 | 65 | In Hertz, every notification is a model. If you want to create a new notification type, just create 66 | a new model inheriting from `Hertz::Notification`: 67 | 68 | ```ruby 69 | class CommentNotification < Hertz::Notification 70 | end 71 | ``` 72 | Since not all notifications might implement interfaces for all couriers, you have to manually 73 | specify which couriers they implement via `deliver_by`: 74 | 75 | ```ruby 76 | class CommentNotification < Hertz::Notification 77 | deliver_by :sms, :email 78 | end 79 | ``` 80 | 81 | Notifications are not required to implement any couriers. 82 | 83 | You can set common couriers (i.e. couriers that will be used for all notifications) by putting the 84 | following into an 85 | initializer: 86 | 87 | ```ruby 88 | Hertz.configure do |config| 89 | config.common_couriers = [:sms, :email] 90 | end 91 | ``` 92 | 93 | ### Attaching metadata to a notification 94 | 95 | You can attach custom metadata to a notification, but make sure it can be cleanly stored in an 96 | hstore: 97 | 98 | ```ruby 99 | notification = CommentNotification.new(meta: { comment_id: comment.id }) 100 | user.notify(notification) 101 | ``` 102 | 103 | You can then unserialize any data in the model: 104 | 105 | ```ruby 106 | class CommentNotification < Hertz::Notification 107 | def comment 108 | Comment.find(meta['comment_id']) 109 | end 110 | end 111 | ``` 112 | 113 | Note that you should always access your metadata with string keys, regardless of the type you use 114 | when attaching it. 115 | 116 | ### Notifying users 117 | 118 | You can use `#notify` for notifying a user: 119 | 120 | ```ruby 121 | user.notify(CommentNotification.new(meta: { comment_id: comment.id })) 122 | # or 123 | user.notify(CommentNotification, comment_id: comment.id) 124 | ``` 125 | 126 | You can access a user's notifications with `#notifications`: 127 | 128 | ```ruby 129 | current_user.notifications 130 | current_user.notifications.read 131 | current_user.notifications.unread 132 | ``` 133 | 134 | You can also mark notifications as read/unread: 135 | 136 | ```ruby 137 | notification.mark_as_read 138 | notification.mark_as_unread 139 | ``` 140 | 141 | ### Tracking delivery status 142 | 143 | Hertz provides an API couriers can use to mark the notification as delivered. This allows you to 144 | know which couriers have successfully delivered your notifications and helps prevent double 145 | deliveries: 146 | 147 | ```ruby 148 | notification.delivered_with?(:email) # => false 149 | notification.mark_delivered_with(:email) # => Hertz::Delivery 150 | notification.delivered_with?(:email) # => true 151 | ``` 152 | 153 | Hertz does not enforce usage of the delivery status API in any way, so some couriers may not take 154 | advantage of it. 155 | 156 | ## Available couriers 157 | 158 | - [hertz-twilio](https://github.com/aldesantis/hertz-twilio): delivers notifications by SMS with the 159 | Twilio API. 160 | - [hertz-email](https://github.com/aldesantis/hertz-email): delivers notifications by email with 161 | ActionMailer. 162 | 163 | ## Contributing 164 | 165 | Bug reports and pull requests are welcome on GitHub at https://github.com/aldesantis/hertz. 166 | 167 | ## License 168 | 169 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 170 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /app/models/hertz/delivery.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hertz 4 | class Delivery < ActiveRecord::Base 5 | belongs_to :notification, inverse_of: :deliveries 6 | 7 | validates :notification, presence: true 8 | validates :courier, presence: true 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/models/hertz/notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hertz 4 | class Notification < ActiveRecord::Base 5 | @couriers = [] 6 | 7 | scope :read, -> { where 'read_at IS NOT NULL' } 8 | scope :unread, -> { where 'read_at IS NULL' } 9 | 10 | belongs_to :receiver, inverse_of: :notifications, polymorphic: true 11 | has_many :deliveries, inverse_of: :notification 12 | 13 | after_commit :deliver, on: %i[create update] 14 | 15 | class << self 16 | def couriers 17 | @couriers ||= [] 18 | end 19 | 20 | protected 21 | 22 | def deliver_by(*couriers) 23 | @couriers = couriers.flatten.map(&:to_sym) 24 | end 25 | end 26 | 27 | def read? 28 | read_at.present? 29 | end 30 | 31 | def unread? 32 | read_at.nil? 33 | end 34 | 35 | def mark_as_read 36 | update read_at: Time.zone.now 37 | end 38 | 39 | def mark_as_unread 40 | update read_at: nil 41 | end 42 | 43 | def delivered_with?(courier) 44 | deliveries.where(courier: courier).exists? 45 | end 46 | 47 | def mark_delivered_with(courier) 48 | deliveries.create(courier: courier) 49 | end 50 | 51 | private 52 | 53 | def deliver 54 | Hertz::NotificationDeliverer.deliver(self) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # This command will automatically be run when you run "rails" with Rails 4 gems installed from the root of your application. 5 | 6 | ENGINE_ROOT = File.expand_path('..', __dir__) 7 | ENGINE_PATH = File.expand_path('../lib/hertz/engine', __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 11 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 12 | 13 | require 'rails/all' 14 | require 'rails/engine/commands' 15 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems" 4 | require "bundler" 5 | 6 | Bundler.require :default, :development 7 | 8 | Combustion.initialize! :all 9 | run Combustion::Application 10 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Hertz::Engine.routes.draw do 4 | end 5 | -------------------------------------------------------------------------------- /db/migrate/20160415174901_create_hertz_notifications.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateHertzNotifications < ActiveRecord::Migration[5.0] 4 | def change 5 | enable_extension 'hstore' 6 | 7 | create_table :hertz_notifications do |t| 8 | t.string :type, null: false 9 | t.string :receiver_type, null: false 10 | t.integer :receiver_id, null: false 11 | t.hstore :meta, default: {}, null: false 12 | t.datetime :read_at 13 | t.datetime :created_at, null: false 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20160627084018_create_hertz_notification_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateHertzNotificationDeliveries < ActiveRecord::Migration[5.0] 4 | def change 5 | create_table :hertz_notification_deliveries do |t| 6 | t.integer :notification_id, null: false 7 | t.index :notification_id 8 | t.foreign_key :hertz_notifications, column: :notification_id, on_delete: :cascade 9 | t.string :courier, null: false 10 | t.datetime :created_at, null: false 11 | t.index [:notification_id, :courier], unique: true, name: 'index_hertz_notification_deliveries_on_notification_and_courier' 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20160628084342_rename_notification_deliveries_to_deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameNotificationDeliveriesToDeliveries < ActiveRecord::Migration[5.0] 4 | def change 5 | rename_table :hertz_notification_deliveries, :hertz_deliveries 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200112143142_convert_notification_meta_to_jsonb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ConvertNotificationMetaToJsonb < ActiveRecord::Migration[5.0] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | change_column_null :hertz_notifications, :meta, true 8 | change_column_default :hertz_notifications, :meta, nil 9 | change_column :hertz_notifications, :meta, 'jsonb USING meta::jsonb' 10 | change_column_default :hertz_notifications, :meta, {} 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /hertz.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path('lib', __dir__) 4 | 5 | # Maintain your gem's version: 6 | require 'hertz/version' 7 | 8 | # Describe your gem and declare its dependencies: 9 | Gem::Specification.new do |s| 10 | s.name = 'hertz' 11 | s.version = Hertz::VERSION 12 | s.authors = ['Alessandro Desantis'] 13 | s.email = ['desa.alessandro@gmail.com'] 14 | s.homepage = 'https://github.com/aldesantis/hertz' 15 | s.summary = 'A Rails engine for in-app notifications.' 16 | s.license = 'MIT' 17 | 18 | s.files = Dir['{app,config,db,lib}/**/*', 'MIT-LICENSE', 'Rakefile', 'README.md'] 19 | s.test_files = Dir['spec/**/*'] 20 | 21 | s.add_dependency 'rails', '>= 5.2.0', '< 7' 22 | 23 | s.add_development_dependency 'combustion' 24 | s.add_development_dependency 'coveralls' 25 | s.add_development_dependency 'database_cleaner' 26 | s.add_development_dependency 'factory_bot_rails' 27 | s.add_development_dependency 'faker' 28 | s.add_development_dependency 'fuubar' 29 | s.add_development_dependency 'gem-release' 30 | s.add_development_dependency 'pg' 31 | s.add_development_dependency 'rspec-rails' 32 | s.add_development_dependency 'rubocop' 33 | s.add_development_dependency 'rubocop-rspec' 34 | end 35 | -------------------------------------------------------------------------------- /lib/generators/hertz/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hertz 4 | class InstallGenerator < Rails::Generators::Base 5 | source_root File.expand_path('templates', __dir__) 6 | 7 | def copy_initializer_file 8 | copy_file 'initializer.rb', 'config/initializers/hertz.rb' 9 | rake 'hertz:install:migrations' 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/generators/hertz/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Hertz.configure do |config| 4 | end 5 | -------------------------------------------------------------------------------- /lib/hertz.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hertz/engine' 4 | 5 | require 'hertz/notifiable' 6 | require 'hertz/notification_deliverer' 7 | 8 | module Hertz 9 | class << self 10 | def configure 11 | yield self 12 | end 13 | 14 | def common_couriers=(couriers) 15 | @common_couriers = [couriers].flatten.map(&:to_sym) 16 | end 17 | 18 | def common_couriers 19 | @common_couriers ||= [] 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/hertz/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hertz 4 | class Engine < ::Rails::Engine 5 | isolate_namespace Hertz 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/hertz/notifiable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hertz 4 | module Notifiable 5 | def self.included(base) 6 | base.class_eval do 7 | has_many :notifications, 8 | class_name: 'Hertz::Notification', 9 | as: :receiver, 10 | inverse_of: :receiver, 11 | dependent: :destroy 12 | end 13 | end 14 | 15 | def notify(notification_or_klass, meta = {}) 16 | notification = if notification_or_klass.is_a?(Class) 17 | notification_or_klass.new(meta: meta) 18 | else 19 | notification_or_klass 20 | end 21 | 22 | notification.receiver = self 23 | 24 | notifications << notification 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/hertz/notification_deliverer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hertz 4 | class NotificationDeliverer 5 | class << self 6 | def deliver(notification) 7 | couriers_for(notification).each do |courier| 8 | build_courier(courier).deliver_notification(notification) 9 | end 10 | end 11 | 12 | private 13 | 14 | def couriers_for(notification) 15 | (notification.class.couriers + Hertz.common_couriers).uniq 16 | end 17 | 18 | def build_courier(courier) 19 | "Hertz::#{courier.to_s.camelcase}".constantize 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/hertz/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Hertz 4 | VERSION = '2.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/factories/hertz/deliveries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :delivery, class: 'Hertz::Delivery' do 5 | association :notification, strategy: :build 6 | courier { 'test' } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/factories/hertz/notifications.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :notification, class: 'Hertz::Notification' do 5 | type { 'Hertz::Notification' } 6 | association :receiver, factory: :user, strategy: :build 7 | 8 | trait :read do 9 | read_at { Time.zone.now } 10 | end 11 | 12 | factory :test_notification, class: 'TestNotification' do 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :user do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/hertz/hertz_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hertz do 4 | describe '.configure' do 5 | after { described_class.common_couriers = [] } 6 | 7 | it 'configures the module' do 8 | expect { 9 | described_class.configure do |config| 10 | config.common_couriers = [:test] 11 | end 12 | }.to change(described_class, :common_couriers).to([:test]) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/hertz/notifiable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hertz::Notifiable do 4 | let(:user) { create(:user) } 5 | 6 | describe '.notifications' do 7 | let!(:notification) { create(:notification, receiver: user) } 8 | 9 | it "returns the receiver's notifications" do 10 | expect(user.notifications).to eq([notification]) 11 | end 12 | end 13 | 14 | describe '#notify' do 15 | before do 16 | stub_const('TestNotification', Class.new(Hertz::Notification)) 17 | end 18 | 19 | context 'with a notification object' do 20 | subject { -> { user.notify(TestNotification.new) } } 21 | 22 | it 'notifies the receiver' do 23 | expect(subject).to change(user.notifications, :count).by(1) 24 | end 25 | end 26 | 27 | context 'with a notification class' do 28 | subject { -> { user.notify(TestNotification, foo: 'bar') } } 29 | 30 | it 'notifies the receiver' do 31 | expect(subject).to change(user.notifications, :count).by(1) 32 | end 33 | 34 | it 'sets the provided meta' do 35 | subject.call 36 | expect(user.notifications.last.meta).to eq('foo' => 'bar') 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/hertz/notification_deliverer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hertz::NotificationDeliverer do 4 | subject { described_class } 5 | 6 | before do 7 | stub_const('Hertz::Test', (Class.new do 8 | def self.deliver_notification(_notification); end 9 | end)) 10 | end 11 | 12 | describe '#deliver' do 13 | let(:notification) do 14 | stub_const('TestNotification', (Class.new(Hertz::Notification) do 15 | deliver_by :test 16 | end)).new 17 | end 18 | 19 | it 'delivers the notification through the couriers' do 20 | expect(Hertz::Test).to receive(:deliver_notification) 21 | .once 22 | .with(notification) 23 | 24 | subject.deliver(notification) 25 | end 26 | 27 | context 'when common couriers are defined' do 28 | before do 29 | stub_const('Hertz::Common', (Class.new do 30 | def self.deliver_notification(_notification); end 31 | end)) 32 | 33 | allow(Hertz).to receive(:common_couriers).and_return([:common]) 34 | allow(Hertz::Test).to receive(:deliver_notification) 35 | allow(Hertz::Common).to receive(:deliver_notification) 36 | end 37 | 38 | it 'uses common couriers' do 39 | expect(Hertz::Test).to receive(:deliver_notification) 40 | .once 41 | .with(notification) 42 | 43 | subject.deliver(notification) 44 | end 45 | 46 | it 'uses model-specific couriers' do 47 | expect(Hertz::Common).to receive(:deliver_notification) 48 | .once 49 | .with(notification) 50 | 51 | subject.deliver(notification) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/internal/app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aldesantis/hertz/8761911313677b30c65b42bb93725083edd1d0f4/spec/internal/app/assets/config/manifest.js -------------------------------------------------------------------------------- /spec/internal/app/models/test_notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestNotification < Hertz::Notification 4 | end 5 | -------------------------------------------------------------------------------- /spec/internal/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ActiveRecord::Base 4 | include Hertz::Notifiable 5 | 6 | def hertz_email 7 | email 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: postgresql 3 | database: hertz_test 4 | -------------------------------------------------------------------------------- /spec/internal/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | # Add your own routes here, or remove this file if you don't have need for it. 5 | end 6 | -------------------------------------------------------------------------------- /spec/internal/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define do 4 | create_table(:users, force: true) do |t| 5 | t.string :email 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/internal/log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log -------------------------------------------------------------------------------- /spec/internal/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aldesantis/hertz/8761911313677b30c65b42bb93725083edd1d0f4/spec/internal/public/favicon.ico -------------------------------------------------------------------------------- /spec/models/hertz/notification_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Hertz::Notification do 4 | subject { build(:notification) } 5 | 6 | describe '#read?' do 7 | it 'returns whether read_at is present' do 8 | expect { 9 | subject.read_at = Time.zone.now 10 | }.to change(subject, :read?).from(false).to(true) 11 | end 12 | end 13 | 14 | describe '#unread?' do 15 | it 'returns whether read_at is nil' do 16 | expect { 17 | subject.read_at = Time.zone.now 18 | }.to change(subject, :unread?).from(true).to(false) 19 | end 20 | end 21 | 22 | describe '#mark_as_read' do 23 | subject { create(:notification) } 24 | 25 | it 'touches read_at' do 26 | expect { 27 | subject.mark_as_read 28 | }.to change(subject, :read_at).to( 29 | an_instance_of(ActiveSupport::TimeWithZone) 30 | ) 31 | end 32 | end 33 | 34 | describe '#mark_as_unread' do 35 | subject { create(:notification, :read) } 36 | 37 | it 'nullifies read_at' do 38 | expect { 39 | subject.mark_as_unread 40 | }.to change(subject, :read_at).to(nil) 41 | end 42 | end 43 | 44 | describe '#delivered_with?' do 45 | subject { create(:notification) } 46 | 47 | it 'returns whether it has been delivered with that courier' do 48 | expect { 49 | create(:delivery, notification: subject, courier: :test) 50 | }.to change { subject.delivered_with?(:test) }.from(false).to(true) 51 | end 52 | end 53 | 54 | describe '#mark_delivered_with' do 55 | subject { create(:notification) } 56 | 57 | it 'creates a delivery for that courier' do 58 | expect { 59 | subject.mark_delivered_with(:test) 60 | }.to change(subject.deliveries, :count).by(1) 61 | end 62 | end 63 | 64 | describe '.read' do 65 | before { create(:notification) } 66 | 67 | let!(:read_notification) { create(:notification, :read) } 68 | 69 | it 'returns the unread notifications' do 70 | expect(described_class.read).to eq([read_notification]) 71 | end 72 | end 73 | 74 | describe '.unread' do 75 | let!(:unread_notification) { create(:notification) } 76 | 77 | before { create(:notification, :read) } 78 | 79 | it 'returns the unread notifications' do 80 | expect(described_class.unread).to eq([unread_notification]) 81 | end 82 | end 83 | 84 | describe '.deliver' do 85 | it 'delivers itself upon creation' do 86 | expect(Hertz::NotificationDeliverer).to receive(:deliver) 87 | .with(subject) 88 | .once 89 | 90 | subject.save! 91 | end 92 | 93 | context 'when updating' do 94 | subject { create(:notification) } 95 | 96 | it 'delivers itself upon update' do 97 | expect(Hertz::NotificationDeliverer).to receive(:deliver) 98 | .with(subject) 99 | .once 100 | 101 | subject.save! 102 | end 103 | end 104 | 105 | it "doesn't deliver itself upon destruction" do 106 | expect(Hertz::NotificationDeliverer).not_to receive(:deliver) 107 | .with(subject) 108 | 109 | subject.destroy! 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 5 | # The generated `.rspec` file contains `--require spec_helper` which will cause 6 | # this file to always be loaded, without a need to explicitly require it in any 7 | # files. 8 | # 9 | # Given that it is always loaded, you are encouraged to keep this file as 10 | # light-weight as possible. Requiring heavyweight dependencies from this file 11 | # will add to the boot time of your test suite on EVERY test run, even for an 12 | # individual file that may not need all of that loaded. Instead, consider making 13 | # a separate helper file that requires the additional dependencies and performs 14 | # the additional setup, and require it from the spec files that actually need 15 | # it. 16 | # 17 | # The `.rspec` file also contains a few flags that are not defaults but that 18 | # users commonly want. 19 | # 20 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 21 | require 'coveralls' 22 | Coveralls.wear! 23 | 24 | require 'combustion' 25 | Combustion.initialize! :all 26 | 27 | require 'rspec/rails' 28 | require 'hertz' 29 | 30 | # Load RSpec helpers. 31 | Dir[File.expand_path('spec/support/**/*.rb')].sort.each { |f| require f } 32 | 33 | RSpec.configure do |config| 34 | # rspec-expectations config goes here. You can use an alternate 35 | # assertion/expectation library such as wrong or the stdlib/minitest 36 | # assertions if you prefer. 37 | config.expect_with :rspec do |expectations| 38 | # This option will default to `true` in RSpec 4. It makes the `description` 39 | # and `failure_message` of custom matchers include text for helper methods 40 | # defined using `chain`, e.g.: 41 | # be_bigger_than(2).and_smaller_than(4).description 42 | # # => "be bigger than 2 and smaller than 4" 43 | # ...rather than: 44 | # # => "be bigger than 2" 45 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 46 | end 47 | 48 | # rspec-mocks config goes here. You can use an alternate test double 49 | # library (such as bogus or mocha) by changing the `mock_with` option here. 50 | config.mock_with :rspec do |mocks| 51 | # Prevents you from mocking or stubbing a method that does not exist on 52 | # a real object. This is generally recommended, and will default to 53 | # `true` in RSpec 4. 54 | mocks.verify_partial_doubles = true 55 | end 56 | 57 | # These two settings work together to allow you to limit a spec run 58 | # to individual examples or groups you care about by tagging them with 59 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 60 | # get run. 61 | config.filter_run :focus 62 | config.run_all_when_everything_filtered = true 63 | 64 | # Allows RSpec to persist some state between runs in order to support 65 | # the `--only-failures` and `--next-failure` CLI options. We recommend 66 | # you configure your source control system to ignore this file. 67 | config.example_status_persistence_file_path = 'spec/examples.txt' 68 | 69 | # Limits the available syntax to the non-monkey patched syntax that is 70 | # recommended. For more details, see: 71 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 72 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 73 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 74 | config.disable_monkey_patching! 75 | 76 | # Run specs in random order to surface order dependencies. If you find an 77 | # order dependency and want to debug it, you can fix the order by providing 78 | # the seed, which is printed after each run. 79 | # --seed 1234 80 | config.order = :random 81 | 82 | # Seed global randomization in this process using the `--seed` CLI option. 83 | # Setting this allows you to use `--seed` to deterministically reproduce 84 | # test failures related to randomization by passing the same `--seed` value 85 | # as the one that triggered the failure. 86 | Kernel.srand config.seed 87 | 88 | config.infer_spec_type_from_file_location! 89 | end 90 | -------------------------------------------------------------------------------- /spec/support/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'database_cleaner' 4 | 5 | RSpec.configure do |config| 6 | config.before(:suite) do 7 | DatabaseCleaner.clean_with(:truncation, 8 | except: %w[spatial_ref_sys schema_migrations]) 9 | end 10 | 11 | config.before(:each) { DatabaseCleaner.strategy = :truncation } 12 | 13 | config.before(:each, js: true) do 14 | DatabaseCleaner.strategy = :truncation, 15 | { except: %w[spatial_ref_sys schema_migrations] } 16 | end 17 | 18 | config.before(:each) { DatabaseCleaner.start } 19 | config.append_after(:each) { DatabaseCleaner.clean } 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'factory_bot_rails' 4 | 5 | RSpec.configure do |config| 6 | config.include FactoryBot::Syntax::Methods 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/faker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faker' 4 | --------------------------------------------------------------------------------