├── .gitignore ├── .rubocop.yml ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── app ├── controllers │ └── correspondent │ │ ├── application_controller.rb │ │ └── notifications_controller.rb ├── jobs │ └── correspondent │ │ └── application_job.rb └── models │ └── correspondent │ ├── application_record.rb │ └── notification.rb ├── bin └── rails ├── config └── routes.rb ├── correspondent.gemspec ├── lib ├── correspondent.rb ├── correspondent │ ├── engine.rb │ └── version.rb ├── generators │ └── correspondent │ │ └── install │ │ ├── install_generator.rb │ │ └── templates │ │ └── create_correspondent_notifications.rb └── tasks │ └── correspondent_tasks.rake └── test ├── benchmarks └── notifies_penalty_test.rb ├── controllers └── notifications_controller_test.rb ├── correspondent_test.rb ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── javascripts │ │ │ └── application.js │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── application_record.rb │ │ ├── concerns │ │ │ └── .keep │ │ ├── promotion.rb │ │ ├── purchase.rb │ │ ├── store.rb │ │ └── user.rb │ └── views │ │ ├── application_mailer │ │ ├── purchase_email.html.erb │ │ └── refund_email.html.erb │ │ └── layouts │ │ ├── mailer.html.erb │ │ └── mailer.text.erb ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ └── update ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── backtrace_silencers.rb │ │ ├── cors.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ └── spring.rb ├── db │ ├── migrate │ │ ├── 20190226001839_create_users.rb │ │ ├── 20190226002057_create_purchases.rb │ │ ├── 20190226182148_create_correspondent_notifications.rb │ │ ├── 20190226212733_create_promotions.rb │ │ ├── 20190228192922_add_link_url_to_notifications.rb │ │ ├── 20190307223149_create_stores.rb │ │ └── 20190307224854_add_referrer_url_to_notifications.rb │ └── schema.rb └── log │ └── .keep ├── models └── notification_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3 5 | test/dummy/db/*.sqlite3-journal 6 | test/dummy/db/ 7 | test/dummy/log/*.log 8 | test/dummy/node_modules/ 9 | test/dummy/yarn-error.log 10 | test/dummy/tmp/ 11 | .ruby-version 12 | .ruby-gemset 13 | coverage/ 14 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rails 4 | - rubocop-minitest 5 | 6 | AllCops: 7 | NewCops: enable 8 | Exclude: 9 | - test/dummy/**/* 10 | - coverage/**/* 11 | - lib/generators/**/templates/* 12 | - vendor/bundle/**/* 13 | 14 | Rails: 15 | Enabled: true 16 | Layout/LineLength: 17 | Max: 120 18 | Metrics/MethodLength: 19 | Max: 15 20 | Metrics/BlockLength: 21 | Exclude: 22 | - test/**/* 23 | Style/GuardClause: 24 | Enabled: false 25 | Style/StringLiterals: 26 | EnforcedStyle: double_quotes 27 | Gemspec/RequiredRubyVersion: 28 | Enabled: false 29 | Style/DocumentDynamicEvalDefinition: 30 | Enabled: false 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Firstly, all contributions are greatly appreciated. Be it opening an issue proposing an improvement, submitting a pull request fixing a bug or any other assistance is helpful. 4 | 5 | ## Issues 6 | 7 | If you have found a bug or have a suggestion, please make a brief search in the issues section to check if something similar has not been reported already. 8 | 9 | **Bugs** 10 | 11 | When describing bugs in the gem, please include steps for reproducing the issue. That makes it easier to identify the problem's source and solve it. 12 | 13 | **Suggestions** 14 | 15 | These are mostly pretty open, but try to be concise in your explanation of what is being suggested. 16 | 17 | ## Pull requests 18 | 19 | If you're addressing an issue, please refer to it in your pull request's comment for tracking. 20 | 21 | 1. Fork it ( https://github.com/vinistock/correspondent/fork ) 22 | 2. Create your feature branch (git checkout -b my-feature) 23 | 3. Commit your changes (git commit -am 'Add my feature') 24 | 4. Push to the branch (git push origin my-feature) 25 | 5. Create a new Pull Request -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec 5 | 6 | gem "activerecord-jdbcsqlite3-adapter", ">= 52.3", platform: :jruby 7 | gem "byebug", platforms: %i[mri mingw x64_mingw] 8 | gem "ruby-prof", platform: :mri 9 | gem "simplecov", require: false, group: :test 10 | gem "sqlite3", platforms: %i[mri mingw x64_mingw] 11 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | correspondent (1.0.1) 5 | async 6 | rails 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actioncable (5.2.6) 12 | actionpack (= 5.2.6) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | actionmailer (5.2.6) 16 | actionpack (= 5.2.6) 17 | actionview (= 5.2.6) 18 | activejob (= 5.2.6) 19 | mail (~> 2.5, >= 2.5.4) 20 | rails-dom-testing (~> 2.0) 21 | actionpack (5.2.6) 22 | actionview (= 5.2.6) 23 | activesupport (= 5.2.6) 24 | rack (~> 2.0, >= 2.0.8) 25 | rack-test (>= 0.6.3) 26 | rails-dom-testing (~> 2.0) 27 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 28 | actionview (5.2.6) 29 | activesupport (= 5.2.6) 30 | builder (~> 3.1) 31 | erubi (~> 1.4) 32 | rails-dom-testing (~> 2.0) 33 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 34 | activejob (5.2.6) 35 | activesupport (= 5.2.6) 36 | globalid (>= 0.3.6) 37 | activemodel (5.2.6) 38 | activesupport (= 5.2.6) 39 | activerecord (5.2.6) 40 | activemodel (= 5.2.6) 41 | activesupport (= 5.2.6) 42 | arel (>= 9.0) 43 | activestorage (5.2.6) 44 | actionpack (= 5.2.6) 45 | activerecord (= 5.2.6) 46 | marcel (~> 1.0.0) 47 | activesupport (5.2.6) 48 | concurrent-ruby (~> 1.0, >= 1.0.2) 49 | i18n (>= 0.7, < 2) 50 | minitest (~> 5.1) 51 | tzinfo (~> 1.1) 52 | arel (9.0.0) 53 | ast (2.4.2) 54 | async (1.28.9) 55 | console (~> 1.10) 56 | nio4r (~> 2.3) 57 | timers (~> 4.1) 58 | benchmark-ips (2.9.1) 59 | brakeman (5.1.1) 60 | builder (3.2.4) 61 | byebug (11.1.3) 62 | code_analyzer (0.5.2) 63 | sexp_processor 64 | concurrent-ruby (1.1.9) 65 | console (1.10.1) 66 | fiber-local 67 | crass (1.0.6) 68 | docile (1.3.5) 69 | erubi (1.10.0) 70 | erubis (2.7.0) 71 | fiber-local (1.0.0) 72 | globalid (0.5.2) 73 | activesupport (>= 5.0) 74 | i18n (1.8.10) 75 | concurrent-ruby (~> 1.0) 76 | json (2.5.1) 77 | loofah (2.12.0) 78 | crass (~> 1.0.2) 79 | nokogiri (>= 1.5.9) 80 | mail (2.7.1) 81 | mini_mime (>= 0.1.1) 82 | marcel (1.0.1) 83 | method_source (1.0.0) 84 | mini_mime (1.1.1) 85 | minitest (5.14.4) 86 | nio4r (2.5.8) 87 | nokogiri (1.12.4-arm64-darwin) 88 | racc (~> 1.4) 89 | parallel (1.20.1) 90 | parser (3.0.1.1) 91 | ast (~> 2.4.1) 92 | purdytest (2.0.0) 93 | minitest (~> 5.5) 94 | racc (1.5.2) 95 | rack (2.2.3) 96 | rack-test (1.1.0) 97 | rack (>= 1.0, < 3) 98 | rails (5.2.6) 99 | actioncable (= 5.2.6) 100 | actionmailer (= 5.2.6) 101 | actionpack (= 5.2.6) 102 | actionview (= 5.2.6) 103 | activejob (= 5.2.6) 104 | activemodel (= 5.2.6) 105 | activerecord (= 5.2.6) 106 | activestorage (= 5.2.6) 107 | activesupport (= 5.2.6) 108 | bundler (>= 1.3.0) 109 | railties (= 5.2.6) 110 | sprockets-rails (>= 2.0.0) 111 | rails-dom-testing (2.0.3) 112 | activesupport (>= 4.2.0) 113 | nokogiri (>= 1.6) 114 | rails-html-sanitizer (1.4.2) 115 | loofah (~> 2.3) 116 | rails_best_practices (1.21.0) 117 | activesupport 118 | code_analyzer (>= 0.5.2) 119 | erubis 120 | i18n 121 | json 122 | require_all (~> 3.0) 123 | ruby-progressbar 124 | railties (5.2.6) 125 | actionpack (= 5.2.6) 126 | activesupport (= 5.2.6) 127 | method_source 128 | rake (>= 0.8.7) 129 | thor (>= 0.19.0, < 2.0) 130 | rainbow (3.0.0) 131 | rake (13.0.6) 132 | regexp_parser (2.1.1) 133 | require_all (3.0.0) 134 | rexml (3.2.5) 135 | rubocop (1.12.1) 136 | parallel (~> 1.10) 137 | parser (>= 3.0.0.0) 138 | rainbow (>= 2.2.2, < 4.0) 139 | regexp_parser (>= 1.8, < 3.0) 140 | rexml 141 | rubocop-ast (>= 1.2.0, < 2.0) 142 | ruby-progressbar (~> 1.7) 143 | unicode-display_width (>= 1.4.0, < 3.0) 144 | rubocop-ast (1.7.0) 145 | parser (>= 3.0.1.1) 146 | rubocop-minitest (0.12.1) 147 | rubocop (>= 0.90, < 2.0) 148 | rubocop-performance (1.10.2) 149 | rubocop (>= 0.90.0, < 2.0) 150 | rubocop-ast (>= 0.4.0) 151 | rubocop-rails (2.9.1) 152 | activesupport (>= 4.2.0) 153 | rack (>= 1.1) 154 | rubocop (>= 0.90.0, < 2.0) 155 | ruby-prof (1.4.3) 156 | ruby-progressbar (1.11.0) 157 | sexp_processor (4.15.3) 158 | simplecov (0.21.2) 159 | docile (~> 1.1) 160 | simplecov-html (~> 0.11) 161 | simplecov_json_formatter (~> 0.1) 162 | simplecov-html (0.12.3) 163 | simplecov_json_formatter (0.1.3) 164 | sprockets (4.0.2) 165 | concurrent-ruby (~> 1.0) 166 | rack (> 1, < 3) 167 | sprockets-rails (3.2.2) 168 | actionpack (>= 4.0) 169 | activesupport (>= 4.0) 170 | sprockets (>= 3.0.0) 171 | sqlite3 (1.4.2) 172 | thor (1.1.0) 173 | thread_safe (0.3.6) 174 | timers (4.3.3) 175 | tzinfo (1.2.9) 176 | thread_safe (~> 0.1) 177 | unicode-display_width (2.0.0) 178 | websocket-driver (0.7.5) 179 | websocket-extensions (>= 0.1.0) 180 | websocket-extensions (0.1.5) 181 | 182 | PLATFORMS 183 | ruby 184 | 185 | DEPENDENCIES 186 | activerecord-jdbcsqlite3-adapter (>= 52.3) 187 | benchmark-ips 188 | brakeman 189 | bundler 190 | byebug 191 | correspondent! 192 | minitest 193 | purdytest 194 | rails_best_practices 195 | rubocop 196 | rubocop-minitest 197 | rubocop-performance 198 | rubocop-rails 199 | ruby-prof 200 | simplecov 201 | sqlite3 202 | 203 | BUNDLED WITH 204 | 2.2.27 205 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Vinicius Stock 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 | This unfinished experiment is no longer active. This was never actually ready and really shouldn't be used. 2 | 3 | # Correspondent 4 | 5 | Dead simple configurable user notifications using the Correspondent engine! 6 | 7 | Configure subscribers and publishers and let Correspondent deal with all notification work with very little overhead. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'correspondent' 15 | ``` 16 | 17 | And then execute: 18 | 19 | ```bash 20 | $ bundle 21 | ``` 22 | 23 | Create the necessary migrations: 24 | 25 | ```bash 26 | $ rails g correspondent:install 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Model configuration 32 | 33 | Notifications can easily be setup using Correspondent. The following example goes through the basic usage. There are only two steps for the basic configuration: 34 | 35 | 1. Invoke notifies and configure the subscriber (user in this case), the triggers (method purchase in this case) and desired options 36 | 2. Define the to_notification method to configure information that needs to be used to create notifications 37 | 38 | ```ruby 39 | # Example model using Correspondent 40 | # app/models/purchase.rb 41 | class Purchase < ApplicationRecord 42 | belongs_to :user 43 | belongs_to :store 44 | 45 | # Notifies configuration 46 | # First argument is the subscriber (the one that receives a notification). Can be an NxN association as well (e.g.: users) which will create a notification for each associated record. 47 | # Second argument are the triggers (the method inside that model that triggers notifications). Can be an array of symbols for multiple triggers for the same entity. 48 | # Third argument are generic options as a hash 49 | notifies :user, :purchase, avoid_duplicates: true 50 | 51 | # Many notifies definitions can be used for different subscribers 52 | # In the following case, every time purchase is invoked the following will happen: 53 | # 1. A notification will be created for `user` 54 | # 2. A notification will be created for `store` 55 | # 3. An email will be triggered using the `StoreMailer` (invoking a method called purchase_email) 56 | notifies :store, :purchase, avoid_duplicates: true, mailer: StoreMailer 57 | 58 | # `notifies` will hook into the desired triggers. 59 | # Every time this method is invoked by an instance of Purchase 60 | # a notification will be created in the database using the 61 | # `to_notification` method. The handling of notifications is 62 | # done asynchronously to cause as little overhead as possible. 63 | def purchase 64 | # some business logic 65 | end 66 | 67 | # The to_notification method returns the information to be 68 | # used for creating a notification. This will be invoked automatically 69 | # by the gem when a trigger occurs. 70 | # When calling this method, entity and trigger will be passed. Entity 71 | # is the subscriber (in this example, `user`). Trigger is the method 72 | # that triggered the notification. With this approach, the hash 73 | # built to pass information can vary based on different triggers. 74 | # If entity and trigger will not be used, this can simply be defined as 75 | # 76 | # def to_notification(*) 77 | # # some hash 78 | # end 79 | def to_notification(entity:, trigger:) 80 | { 81 | title: "Purchase ##{id} for #{entity} #{send(entity).name}", 82 | content: "Congratulations on your recent #{trigger} of #{name}", 83 | image_url: "", 84 | link_url: "/purchases/#{id}", 85 | referrer_url: "/stores/#{store.id}" 86 | } 87 | end 88 | end 89 | ``` 90 | 91 | Correspondent can also trigger emails if desired. To trigger emails, the mailer class should be passed as an object and should implement a method follwing the naming convention. 92 | 93 | ```ruby 94 | # app/models/purchase.rb 95 | 96 | class Purchase < ApplicationRecord 97 | belongs_to :user 98 | 99 | # Pass the desired mailer in the `mailer:` option 100 | notifies :user, :purchase, mailer: ApplicationMailer 101 | 102 | def purchase 103 | # some business logic 104 | end 105 | end 106 | 107 | # app/mailers/application_mailer.rb 108 | class ApplicationMailer < ActionMailer::Base 109 | default from: 'from@example.com' 110 | layout 'mailer' 111 | 112 | # The mailer should implement methods following the naming convention of 113 | # #{trigger}_email(triggering_instance) 114 | # 115 | # In this case, the `trigger` is the method purchase, so Correspondent will look for 116 | # the purchase_email method. It will always pass the instance that triggered the email 117 | # as an argument. 118 | def purchase_email(purchase) 119 | @purchase = purchase 120 | mail(to: purchase.user.email, subject: "Congratulations on the purchase of #{purchase.name}") 121 | end 122 | end 123 | ``` 124 | 125 | To reference the created notifications in the desired model, use the following association: 126 | 127 | ```ruby 128 | # app/models/purchase.rb 129 | 130 | class User < ApplicationRecord 131 | has_many :purchases 132 | has_many :notifications, class_name: "Correspondent::Notification", as: :subscriber 133 | end 134 | 135 | class Purchase < ApplicationRecord 136 | belongs_to :user 137 | has_many :notifications, class_name: "Correspondent::Notification", as: :publisher 138 | end 139 | ``` 140 | 141 | If a specific column is not needed for your project, remove them from the generated migrations and don't return the respective attribute inside the to_notification method. 142 | 143 | ### Options 144 | 145 | The available options, their default values and their explanations are listed below. 146 | 147 | ```ruby 148 | # Avoid duplicates 149 | # Prevents creating new notifications if a non dismissed notification for the same publisher and same subscriber already exists 150 | notifies :some_resouce, :trigger, avoid_duplicates: false 151 | 152 | # Mailer 153 | # The Mailer class that implements the desired mailer triggers to send emails. Default is nil (doesn't send emails). 154 | notifies :some_resouce, :trigger, mailer: nil 155 | 156 | # Email only 157 | # For preventing the creation of notifications and only trigger emails, add the email_only option 158 | notifies :some_resouce, :trigger, email_only: false 159 | 160 | # Conditionals 161 | # If or unless options can be passed either as procs/lambdas or symbols representing the name of a method 162 | # These will be evaluated in an instance context, every time trigger is invoked 163 | notifies :some_resource, :trigger, if: :should_be_notified? 164 | 165 | notifies :some_resource, :trigger, unless: -> { should_be_notified? && is_eligible? } 166 | ``` 167 | 168 | ### JSON API 169 | 170 | Correspondent exposes a few APIs to be used for handling notification logic in the application. 171 | 172 | All APIs use the `stale?` check. So if passing the If-None-Match header, the API will support returning 304 (not modified) if the collection hasn't changed. 173 | 174 | ```json 175 | Parameters 176 | 177 | :subscriber_type -> The subscriber resource name - not in plural (e.g.: user) 178 | :subscriber_id -> The id of the subscriber 179 | 180 | Index 181 | 182 | Retrieves all non dismissed notifications for a given subscriber. 183 | 184 | Request 185 | GET /correspondent/:subscriber_type/:subscriber_id/notifications 186 | 187 | Response 188 | [ 189 | { 190 | "id":20, 191 | "title":"Purchase #1 for user user", 192 | "content":"Congratulations on your recent purchase of purchase", 193 | "image_url":"", 194 | "dismissed":false, 195 | "publisher_type":"Purchase", 196 | "publisher_id":1, 197 | "created_at":"2019-03-01T14:19:31.273Z", 198 | "link_url":"/purchases/1", 199 | "referrer_url":"/stores/1" 200 | } 201 | ] 202 | 203 | Preview 204 | 205 | Returns total number of non dismissed notifications and the newest notification. 206 | 207 | Request 208 | GET /correspondent/:subscriber_type/:subscriber_id/notifications/preview 209 | 210 | Response 211 | { 212 | "count": 3, 213 | "notification": { 214 | "id":20, 215 | "title":"Purchase #1 for user user", 216 | "content":"Congratulations on your recent purchase of purchase", 217 | "image_url":"", 218 | "dismissed":false, 219 | "publisher_type":"Purchase", 220 | "publisher_id":1, 221 | "created_at":"2019-03-01T14:22:31.649Z", 222 | "link_url":"/purchases/1", 223 | "referrer_url":"/stores/1" 224 | } 225 | } 226 | 227 | 228 | Dismiss 229 | 230 | Dismisses a given notification. 231 | 232 | Resquest 233 | PUT /correspondent/:subscriber_type/:subscriber_id/notifications/:notification_id/dismiss 234 | 235 | Response 236 | STATUS no_content (204) 237 | 238 | Destroy 239 | 240 | Destroys a given notification. 241 | 242 | Resquest 243 | DELETE /correspondent/:subscriber_type/:subscriber_id/notifications/:notification_id 244 | 245 | Response 246 | STATUS no_content (204) 247 | ``` 248 | 249 | ## Contributing 250 | 251 | Contributions are very welcome! Don't hesitate to ask if you wish to contribute, but don't yet know how. Please refer to this simple [guideline]. 252 | 253 | [guideline]: https://github.com/vinistock/correspondent/blob/master/CONTRIBUTING.md 254 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "bundler/setup" 5 | rescue LoadError 6 | puts "You must `gem install bundler` and `bundle install` to run rake tasks" 7 | end 8 | 9 | require "rdoc/task" 10 | 11 | RDoc::Task.new(:rdoc) do |rdoc| 12 | rdoc.rdoc_dir = "rdoc" 13 | rdoc.title = "Correspondent" 14 | rdoc.options << "--line-numbers" 15 | rdoc.rdoc_files.include("README.md") 16 | rdoc.rdoc_files.include("lib/**/*.rb") 17 | end 18 | 19 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 20 | load "rails/tasks/engine.rake" 21 | load "rails/tasks/statistics.rake" 22 | 23 | require "bundler/gem_tasks" 24 | require "rake/testtask" 25 | 26 | Rake::TestTask.new(:test) do |t| 27 | t.libs << "test" 28 | t.pattern = Dir["test/**/*_test.rb"].reject { |path| path.include?("benchmarks") } 29 | t.verbose = false 30 | end 31 | 32 | task default: :test 33 | 34 | namespace :test do 35 | Rake::TestTask.new(:benchmark) do |t| 36 | t.libs << "test" 37 | t.pattern = "test/benchmarks/**/*_test.rb" 38 | t.verbose = false 39 | end 40 | end 41 | 42 | task all: :environment do 43 | system( 44 | "brakeman && "\ 45 | "rake && "\ 46 | "rubocop --auto-correct && "\ 47 | "rails_best_practices" 48 | ) 49 | end 50 | -------------------------------------------------------------------------------- /app/controllers/correspondent/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Correspondent 4 | class ApplicationController < ActionController::API # :nodoc: 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/controllers/correspondent/notifications_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_dependency "correspondent/application_controller" 4 | 5 | module Correspondent 6 | # NotificationsController 7 | # 8 | # API for all notifications related 9 | # endpoints. 10 | class NotificationsController < ApplicationController 11 | before_action :find_notification, only: %i[dismiss destroy] 12 | 13 | # index 14 | # 15 | # Returns all notifications for a given subscriber. 16 | def index 17 | notifications = Correspondent::Notification.for_subscriber(params[:subscriber_type], params[:subscriber_id]) 18 | render(json: notifications) if stale?(notifications) 19 | end 20 | 21 | # preview 22 | # 23 | # Returns the newest notification and the total 24 | # number of notifications for the given subscriber. 25 | def preview 26 | notifications = Correspondent::Notification.for_subscriber(params[:subscriber_type], params[:subscriber_id]) 27 | 28 | if stale?(notifications) 29 | render( 30 | json: { 31 | count: notifications.count, 32 | notification: notifications.limit(1).first 33 | } 34 | ) 35 | end 36 | end 37 | 38 | # dismiss 39 | # 40 | # Dismisses a given notification. 41 | def dismiss 42 | @notification&.dismiss! 43 | head(:no_content) 44 | end 45 | 46 | # destroy 47 | # 48 | # Destroys a given notification. 49 | def destroy 50 | @notification&.destroy 51 | head(:no_content) 52 | end 53 | 54 | private 55 | 56 | def find_notification 57 | @notification = Correspondent::Notification 58 | .select(:id, :subscriber_type, :subscriber_id, :publisher_id, :publisher_type) 59 | .find_by(id: params[:id]) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/jobs/correspondent/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Correspondent 4 | class ApplicationJob < ActiveJob::Base # :nodoc: 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/models/correspondent/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Correspondent 4 | class ApplicationRecord < ActiveRecord::Base # :nodoc: 5 | self.abstract_class = true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/models/correspondent/notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Correspondent 4 | # Notification 5 | # 6 | # Model to hold all notification logic. 7 | class Notification < ApplicationRecord 8 | belongs_to :subscriber, polymorphic: true 9 | belongs_to :publisher, polymorphic: true 10 | 11 | validates :publisher, :subscriber, presence: true 12 | before_destroy :delete_cache_entry 13 | 14 | scope :not_dismissed, -> { where(dismissed: false) } 15 | scope :by_parents, lambda { |subscriber, publisher| 16 | select(:id) 17 | .where(subscriber: subscriber, publisher: publisher) 18 | .not_dismissed 19 | } 20 | 21 | scope :for_subscriber, lambda { |type, id| 22 | not_dismissed 23 | .where(subscriber_type: type.capitalize, subscriber_id: id) 24 | .order(id: :desc) 25 | } 26 | 27 | class << self 28 | # create_for! 29 | # 30 | # Creates notification(s) for the given 31 | # +instance+ of the publisher and given 32 | # +entity+ (subscriber). 33 | def create_for!(attrs, options = {}) 34 | attributes = attrs[:instance].to_notification(entity: attrs[:entity], trigger: attrs[:trigger]) 35 | attributes[:publisher] = attrs[:instance] 36 | 37 | relation = attrs[:instance].send(attrs[:entity]) 38 | 39 | if relation.respond_to?(:each) 40 | create_many!(attributes, relation, options) 41 | else 42 | create_single!(attributes, relation, options) 43 | end 44 | end 45 | 46 | # create_many! 47 | # 48 | # Creates a notification for each 49 | # record of the +relation+ so that 50 | # a many to many relationship can 51 | # notify all associated objects. 52 | def create_many!(attributes, relation, options) 53 | relation.each do |record| 54 | unless options[:avoid_duplicates] && by_parents(record, attributes[:publisher]).exists? 55 | create!(attributes.merge(subscriber: record)) 56 | end 57 | end 58 | end 59 | 60 | # create_single! 61 | # 62 | # Creates a single notification for the 63 | # passed entity. 64 | def create_single!(attributes, relation, options) 65 | attributes[:subscriber] = relation 66 | create!(attributes) unless options[:avoid_duplicates] && by_parents(relation, attributes[:publisher]).exists? 67 | end 68 | end 69 | 70 | private_class_method :create_many!, :create_single! 71 | 72 | def as_json(*) 73 | Rails.cache.fetch("correspondent_notification_#{id}") do 74 | attributes.except("updated_at", "subscriber_type", "subscriber_id") 75 | end 76 | end 77 | 78 | def dismiss! 79 | delete_cache_entry 80 | update!(dismissed: true) 81 | end 82 | 83 | private 84 | 85 | def delete_cache_entry 86 | Rails.cache.delete("correspondent_notification_#{id}") 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /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 gems 5 | # installed from the root of your application. 6 | 7 | ENGINE_ROOT = File.expand_path("..", __dir__) 8 | ENGINE_PATH = File.expand_path("../lib/correspondent/engine", __dir__) 9 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) 10 | 11 | # Set up gems listed in the Gemfile. 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 13 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 14 | 15 | require "rails" 16 | # Pick the frameworks you want: 17 | require "active_model/railtie" 18 | require "active_job/railtie" 19 | require "active_record/railtie" 20 | require "action_controller/railtie" 21 | require "action_mailer/railtie" 22 | require "action_view/railtie" 23 | require "sprockets/railtie" 24 | require "rails/test_unit/railtie" 25 | require "rails/engine/commands" 26 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Correspondent::Engine.routes.draw do 4 | scope path: ":subscriber_type/:subscriber_id" do 5 | resources :notifications, only: %i[index destroy] do 6 | collection do 7 | get :preview 8 | end 9 | 10 | member do 11 | put :dismiss 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /correspondent.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path("lib", __dir__) 4 | 5 | require "correspondent/version" 6 | Gem::Specification.new do |spec| 7 | spec.name = "correspondent" 8 | spec.version = Correspondent::VERSION 9 | spec.authors = ["Vinicius Stock"] 10 | spec.email = ["vinicius.stock@outlook.com"] 11 | spec.homepage = "https://github.com/vinistock/correspondent" 12 | spec.summary = "Dead simple configurable user notifications with little overhead." 13 | spec.description = "Dead simple configurable user notifications with little overhead." 14 | spec.license = "MIT" 15 | 16 | spec.files = Dir["{app,config,db,lib}/**/*", 17 | "MIT-LICENSE", 18 | "Rakefile", 19 | "README.md"] 20 | 21 | spec.required_ruby_version = ">= 2.4.0" 22 | spec.add_dependency "async" 23 | spec.add_dependency "rails" 24 | 25 | spec.add_development_dependency "benchmark-ips" 26 | spec.add_development_dependency "brakeman" 27 | spec.add_development_dependency "bundler" 28 | spec.add_development_dependency "minitest" 29 | spec.add_development_dependency "purdytest" 30 | spec.add_development_dependency "rails_best_practices" 31 | spec.add_development_dependency "rubocop" 32 | spec.add_development_dependency "rubocop-minitest" 33 | spec.add_development_dependency "rubocop-performance" 34 | spec.add_development_dependency "rubocop-rails" 35 | end 36 | -------------------------------------------------------------------------------- /lib/correspondent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "correspondent/engine" 4 | require "async" 5 | 6 | module Correspondent # :nodoc: 7 | LAMBDA_PROC_REGEX = /(->.*})|(->.*end)|(proc.*})|(proc.*end)|(Proc\.new.*})|(Proc\.new.*end)/.freeze 8 | 9 | class << self 10 | attr_writer :patched_methods 11 | 12 | # patched_methods 13 | # 14 | # Hash with information about methods 15 | # that need to be patched. 16 | def patched_methods 17 | @patched_methods ||= {}.with_indifferent_access 18 | end 19 | 20 | # trigger_email 21 | # 22 | # Calls the method of a given mailer using the 23 | # trigger. Triggering only happens if a mailer 24 | # has been passed as an option. 25 | # 26 | # Will invoke methods in this manner: 27 | # 28 | # MyMailer.send("make_purchase_email", #) 29 | def trigger_email(data) 30 | data.dig(:options, :mailer).send("#{data[:trigger]}_email", data[:instance]).deliver_now 31 | end 32 | 33 | # << 34 | # 35 | # Adds the notification creation and email sending 36 | # as asynchronous tasks. 37 | def <<(data) 38 | Async do 39 | unless data.dig(:options, :email_only) 40 | Correspondent::Notification.create_for!(data.except(:options), data[:options]) 41 | end 42 | 43 | trigger_email(data) if data.dig(:options, :mailer) 44 | end 45 | end 46 | 47 | # should_notify? 48 | # 49 | # Evaluates the if and unless options within 50 | # the context of a model instance. 51 | def should_notify?(context, opt) 52 | if opt[:if].present? 53 | evaluate_conditional(context, opt[:if]) 54 | elsif opt[:unless].present? 55 | !evaluate_conditional(context, opt[:unless]) 56 | end 57 | end 58 | 59 | # evaluate_conditional 60 | # 61 | # Evaluates if or unless regardless of 62 | # whether it is a proc or a symbol. 63 | def evaluate_conditional(context, if_or_unless) 64 | if if_or_unless.is_a?(Proc) 65 | context.instance_exec(&if_or_unless) 66 | else 67 | context.method(if_or_unless).call 68 | end 69 | end 70 | end 71 | 72 | # notifies 73 | # 74 | # Save trigger info and options into the patched_methods 75 | # hash. 76 | def notifies(entity, triggers, options = {}) 77 | triggers = Array(triggers) 78 | 79 | triggers.each do |trigger| 80 | Correspondent.patched_methods[trigger] ||= [] 81 | Correspondent.patched_methods[trigger] << { entity: entity, options: options } 82 | end 83 | end 84 | 85 | # method_added 86 | # 87 | # Callback to patch methods once they are defined. 88 | # 1. Create an alias of the original method 89 | # 2. Override method by calling the original 90 | # 3. Add Correspondent calls for notifications 91 | def method_added(name) 92 | patch_info = Correspondent.patched_methods.delete(name) 93 | return unless patch_info 94 | 95 | class_eval(<<~PATCH, __FILE__, __LINE__ + 1) 96 | alias_method :original_#{name}, :#{name} 97 | 98 | def #{name}(*args, &block) 99 | result = original_#{name}(*args, &block) 100 | #{build_async_calls(patch_info, name)} 101 | result 102 | end 103 | PATCH 104 | 105 | super 106 | end 107 | 108 | # ActiveRecord on load hook 109 | # 110 | # Extend the module after load so that 111 | # model class methods are available. 112 | ActiveSupport.on_load(:active_record) do 113 | extend Correspondent 114 | end 115 | 116 | private 117 | 118 | # build_async_calls 119 | # 120 | # Builds all async call strings needed 121 | # to patch the method. 122 | def build_async_calls(patch_info, name) # rubocop:disable Metrics/AbcSize 123 | patch_info.map do |info| 124 | info[:options][:unless] = stringify_lambda(info[:options][:unless]) if info[:options][:unless].is_a?(Proc) 125 | info[:options][:if] = stringify_lambda(info[:options][:if]) if info[:options][:if].is_a?(Proc) 126 | 127 | async_call_string(info, name) 128 | end.join("\n") 129 | end 130 | 131 | # async_call_string 132 | # 133 | # Builds the string for an Async call 134 | # to send data to Correspondent callbacks. 135 | def async_call_string(info, name) 136 | <<~ASYNC_CALL 137 | if Correspondent.should_notify?(self, #{info[:options].to_s.delete('"')}) 138 | Async do 139 | Correspondent << { 140 | instance: self, 141 | entity: :#{info[:entity]}, 142 | trigger: :#{name}, 143 | options: #{info[:options].to_s.delete('"')} 144 | } 145 | end 146 | end 147 | ASYNC_CALL 148 | end 149 | 150 | # stringify_lambda 151 | # 152 | # Transform lambda into a string to be used 153 | # in method patching. 154 | def stringify_lambda(lambda) 155 | lambda.source.scan(LAMBDA_PROC_REGEX).flatten.compact.first 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/correspondent/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Correspondent 4 | class Engine < ::Rails::Engine # :nodoc: 5 | isolate_namespace Correspondent 6 | config.generators.api_only = true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/correspondent/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Correspondent 4 | VERSION = "1.0.1" 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/correspondent/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/migration" 4 | 5 | module Correspondent 6 | module Generators 7 | # InstallGenerator 8 | # 9 | # Creates the necessary migrations to be able to 10 | # use the engine. 11 | class InstallGenerator < ::Rails::Generators::Base 12 | include Rails::Generators::Migration 13 | 14 | source_root File.expand_path("templates", __dir__) 15 | desc "Create Correspondent migrations" 16 | 17 | def self.next_migration_number(_path) 18 | if @prev_migration_nr 19 | @prev_migration_nr += 1 20 | else 21 | @prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i 22 | end 23 | 24 | @prev_migration_nr.to_s 25 | end 26 | 27 | def copy_migrations 28 | migration_template "create_correspondent_notifications.rb", 29 | "db/migrate/create_correspondent_notifications.rb", 30 | migration_version: migration_version 31 | 32 | say "\n" 33 | say <<~POST_INSTALL_MESSAGE 34 | Make sure to edit the generated migration and adapt the notifications 35 | attributes according to the application's need. The only attributes 36 | that must be kept are the one listed below and the indices. 37 | 38 | Any other desired attributes can be added and then referenced in the 39 | `to_notification` method. 40 | 41 | publisher_type 42 | publisher_id 43 | subscriber_type 44 | subscriber_id 45 | POST_INSTALL_MESSAGE 46 | end 47 | 48 | def migration_version 49 | "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/generators/correspondent/install/templates/create_correspondent_notifications.rb: -------------------------------------------------------------------------------- 1 | class CreateCorrespondentNotifications < ActiveRecord::Migration<%= migration_version %> 2 | def change 3 | create_table :correspondent_notifications do |t| 4 | t.string :title 5 | t.string :content 6 | t.string :image_url 7 | t.string :link_url 8 | t.string :referrer_url 9 | t.boolean :dismissed, default: false 10 | t.string :publisher_type, null: false 11 | t.integer :publisher_id, null: false 12 | t.string :subscriber_type, null: false 13 | t.integer :subscriber_id, null: false 14 | t.index [:publisher_type, :publisher_id], name: "index_correspondent_on_publisher" 15 | t.index [:subscriber_type, :subscriber_id], name: "index_correspondent_on_subscriber" 16 | t.timestamps 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/tasks/correspondent_tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # desc "Explaining what the task does" 4 | # task :correspondent do 5 | # # Task goes here 6 | # end 7 | -------------------------------------------------------------------------------- /test/benchmarks/notifies_penalty_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "benchmark" 5 | require "benchmark/ips" 6 | 7 | module Correspondent 8 | class NotifiesPenaltyTest < ActiveSupport::TestCase 9 | test "it does not delay methods significantly" do 10 | user = User.create!(name: "user", email: "user@email.com") 11 | store = Store.create!(name: "best buy") 12 | purchase = Purchase.create!(name: "purchase", user: user, store: store) 13 | 14 | times_slower = how_many_times_slower do 15 | Benchmark.ips do |x| 16 | x.config(time: 5, warmup: 2) 17 | x.report("non-patched") { purchase.original_refund } 18 | x.report("patched") { purchase.refund } 19 | x.compare! 20 | end 21 | end 22 | 23 | puts "Patched method is #{times_slower} times slower" 24 | end 25 | 26 | test "the absolute delay time should be smaller than 1ms" do 27 | user = User.create!(name: "user", email: "user@email.com") 28 | store = Store.create!(name: "best buy") 29 | purchase = Purchase.create!(name: "purchase", user: user, store: store) 30 | 31 | patched_time = average_exec_time do 32 | purchase.refund 33 | end 34 | 35 | normal_time = average_exec_time do 36 | purchase.original_refund 37 | end 38 | 39 | puts "Patched method is #{(patched_time - normal_time).round(4)}s slower" 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/controllers/notifications_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module Correspondent 6 | class NotificationsControllerTest < ActionDispatch::IntegrationTest 7 | def setup 8 | @subscriber = User.create!(name: "user", email: "user@email.com") 9 | store = Store.create!(name: "best buy") 10 | publisher = Purchase.create!(name: "purchase", user: @subscriber, store: store) 11 | data = { instance: publisher, entity: :user, trigger: :purchase } 12 | 13 | @notification1 = Correspondent::Notification.create_for!(data) 14 | @notification2 = Correspondent::Notification.create_for!(data) 15 | @notification3 = Correspondent::Notification.create_for!(data) 16 | end 17 | 18 | test "GET index" do 19 | get "/correspondent/user/#{@subscriber.id}/notifications", headers: { accept: "application/json" } 20 | 21 | body = JSON.parse(response.body) 22 | 23 | assert_response :ok 24 | assert_equal body[0]["id"], @notification3.id 25 | assert_equal body[1]["id"], @notification2.id 26 | assert_equal body[2]["id"], @notification1.id 27 | assert_includes response.headers, "eTag" 28 | end 29 | 30 | test "GET preview" do 31 | get "/correspondent/user/#{@subscriber.id}/notifications/preview", headers: { accept: "application/json" } 32 | 33 | body = JSON.parse(response.body) 34 | 35 | assert_response :ok 36 | assert_equal body["notification"]["id"], @notification3.id 37 | assert_equal(3, body["count"]) 38 | assert_includes response.headers, "eTag" 39 | end 40 | 41 | test "PUT dismiss" do 42 | put "/correspondent/user/#{@subscriber.id}/notifications/#{@notification3.id}/dismiss", 43 | headers: { accept: "application/json" } 44 | 45 | assert_response :no_content 46 | assert Correspondent::Notification.find(@notification3.id).dismissed 47 | end 48 | 49 | test "DELETE destroy" do 50 | delete "/correspondent/user/#{@subscriber.id}/notifications/#{@notification3.id}", 51 | headers: { accept: "application/json" } 52 | 53 | assert_response :no_content 54 | assert_not Correspondent::Notification.exists?(id: @notification3.id) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/correspondent_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module Correspondent 6 | class Test < ActiveSupport::TestCase 7 | test "adds hook to extend module after ar load" do 8 | purchase = Purchase.new 9 | assert defined?(purchase.class.notifies) 10 | end 11 | 12 | test "#notifies" do 13 | user = User.create!(name: "user", email: "user@email.com") 14 | store = Store.create!(name: "best buy") 15 | purchase = Purchase.create!(name: "purchase", user: user, store: store) 16 | 17 | method_source = purchase.method(:purchase).source 18 | assert_includes(method_source, "async_calls") 19 | assert(purchase.purchase { 5**5 }) 20 | 21 | method_source = purchase.method(:refund).source 22 | assert_includes(method_source, "async_calls") 23 | assert purchase.refund 24 | 25 | assert_equal 2, ApplicationMailer.deliveries.count 26 | 27 | assert_equal 1, store.notifications.count 28 | assert_equal 1, user.notifications.count 29 | assert_equal 2, purchase.notifications.count 30 | end 31 | 32 | test "#notifies for many to many" do 33 | users = [ 34 | User.create!(name: "user", email: "user@email.com"), 35 | User.create!(name: "user2", email: "user2@email.com") 36 | ] 37 | 38 | promotion = Promotion.create!(users: users, name: "promo") 39 | 40 | method_source = promotion.method(:promote).source 41 | assert_includes(method_source, "async_calls") 42 | assert promotion.promote 43 | 44 | assert_equal 0, ApplicationMailer.deliveries.count 45 | end 46 | 47 | test "#notifies when an error is raised" do 48 | user = User.create!(name: "user", email: "user@email.com") 49 | store = Store.create!(name: "best buy") 50 | purchase = Purchase.create!(name: "purchase", user: user, store: store) 51 | 52 | raises_exception = -> { raise StandardError, "Test error" } 53 | 54 | purchase.stub :purchase, raises_exception do 55 | assert_raises(StandardError) { purchase.purchase } 56 | assert_equal 0, Correspondent::Notification.count 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_directory ../javascripts .js 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require_tree . 15 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::API 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/correspondent/a4c6c5ff2d13ff4f94deb4eacf57d6ee076a4130/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | 5 | def purchase_email(purchase) 6 | @purchase = purchase 7 | mail(to: purchase.user.email, subject: purchase.name) 8 | end 9 | 10 | def refund_email(purchase) 11 | @purchase = purchase 12 | mail(to: purchase.user.email, subject: purchase.name) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/correspondent/a4c6c5ff2d13ff4f94deb4eacf57d6ee076a4130/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/models/promotion.rb: -------------------------------------------------------------------------------- 1 | class Promotion < ApplicationRecord 2 | has_and_belongs_to_many :users 3 | has_many :notifications, class_name: "Correspondent::Notification", as: :publisher 4 | notifies :users, :promote, avoid_duplicates: true 5 | 6 | def promote 7 | true 8 | end 9 | 10 | def to_notification(*) 11 | { 12 | title: "Promotion ##{id} - #{name}", 13 | content: "#{name} is coming to you this spring", 14 | image_url: "", 15 | link_url: "/promotions/#{id}", 16 | referrer_url: "" 17 | } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/dummy/app/models/purchase.rb: -------------------------------------------------------------------------------- 1 | class Purchase < ApplicationRecord 2 | belongs_to :user 3 | belongs_to :store 4 | has_many :notifications, class_name: "Correspondent::Notification", as: :publisher 5 | notifies :user, %i[purchase], mailer: ApplicationMailer, if: :must_be_notified? 6 | notifies :store, :refund, mailer: ApplicationMailer, unless: -> { !must_be_notified? } 7 | 8 | def purchase 9 | yield if block_given? 10 | true 11 | end 12 | 13 | def refund 14 | (1..1000) 15 | .map { |i| i**2 } 16 | .reverse 17 | .uniq 18 | .reduce(:+) 19 | end 20 | 21 | def to_notification(entity:, trigger:) 22 | { 23 | title: "Purchase ##{id} for #{entity} #{send(entity).name}", 24 | content: "Congratulations on your recent #{trigger} of #{name}", 25 | image_url: "", 26 | link_url: "/purchases/#{id}", 27 | referrer_url: "/stores/#{store.id}" 28 | } 29 | end 30 | 31 | private 32 | 33 | def must_be_notified? 34 | true 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/dummy/app/models/store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Store < ApplicationRecord 4 | has_many :purchases 5 | has_many :notifications, class_name: "Correspondent::Notification", as: :subscriber 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | has_and_belongs_to_many :promotions 3 | has_many :purchases 4 | has_many :notifications, class_name: "Correspondent::Notification", as: :subscriber 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/views/application_mailer/purchase_email.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Dummy email

8 |

<%= @purchase.name %>

9 | 10 | 11 | -------------------------------------------------------------------------------- /test/dummy/app/views/application_mailer/refund_email.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Dummy email

8 |

<%= @purchase.name %>

9 | 10 | 11 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a starting point to setup your application. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! 'bin/rails db:setup' 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! 'bin/rails log:clear tmp:clear' 30 | 31 | puts "\n== Restarting application server ==" 32 | system! 'bin/rails restart' 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | puts "\n== Updating database ==" 21 | system! 'bin/rails db:migrate' 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system! 'bin/rails log:clear tmp:clear' 25 | 26 | puts "\n== Restarting application server ==" 27 | system! 'bin/rails restart' 28 | end 29 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | require "action_mailer/railtie" 11 | require "action_view/railtie" 12 | # require "action_cable/engine" 13 | require "sprockets/railtie" 14 | require "rails/test_unit/railtie" 15 | 16 | Bundler.require(*Rails.groups) 17 | require "correspondent" 18 | 19 | module Dummy 20 | class Application < Rails::Application 21 | # Initialize configuration defaults for originally generated Rails version. 22 | config.load_defaults 5.2 23 | 24 | # Settings in config/environments/* take precedence over those specified here. 25 | # Application configuration can go into files in config/initializers 26 | # -- all .rb files in that directory are automatically loaded after loading 27 | # the framework and any gems in your application. 28 | 29 | # Only loads a smaller set of middleware suitable for API only apps. 30 | # Middleware like session, flash, cookies can be added back manually. 31 | # Skip views, helpers and assets when generating a new resource. 32 | config.api_only = true 33 | end 34 | end 35 | 36 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 18 | config.action_controller.perform_caching = true 19 | 20 | config.cache_store = :memory_store 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=172800" 23 | } 24 | else 25 | config.action_controller.perform_caching = false 26 | 27 | config.cache_store = :null_store 28 | end 29 | 30 | # Don't care if the mailer can't send. 31 | config.action_mailer.raise_delivery_errors = false 32 | 33 | config.action_mailer.perform_caching = false 34 | 35 | # Print deprecation notices to the Rails logger. 36 | config.active_support.deprecation = :log 37 | 38 | # Raise an error on page load if there are pending migrations. 39 | config.active_record.migration_error = :page_load 40 | 41 | # Highlight code that triggered database queries in logs. 42 | config.active_record.verbose_query_logs = true 43 | 44 | 45 | # Raises error for missing translations 46 | # config.action_view.raise_on_missing_translations = true 47 | 48 | # Use an evented file watcher to asynchronously detect changes in source code, 49 | # routes, locales, etc. This feature depends on the listen gem. 50 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 51 | end 52 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 18 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 19 | # config.require_master_key = true 20 | 21 | # Disable serving static files from the `/public` folder by default since 22 | # Apache or NGINX already handles this. 23 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 24 | 25 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 26 | # config.action_controller.asset_host = 'http://assets.example.com' 27 | 28 | # Specifies the header that your server uses for sending files. 29 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 30 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 31 | 32 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 33 | # config.force_ssl = true 34 | 35 | # Use the lowest log level to ensure availability of diagnostic information 36 | # when problems arise. 37 | config.log_level = :debug 38 | 39 | # Prepend all log lines with the following tags. 40 | config.log_tags = [ :request_id ] 41 | 42 | # Use a different cache store in production. 43 | # config.cache_store = :mem_cache_store 44 | 45 | # Use a real queuing backend for Active Job (and separate queues per environment) 46 | # config.active_job.queue_adapter = :resque 47 | # config.active_job.queue_name_prefix = "dummy_#{Rails.env}" 48 | 49 | config.action_mailer.perform_caching = false 50 | 51 | # Ignore bad email addresses and do not raise email delivery errors. 52 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 53 | # config.action_mailer.raise_delivery_errors = false 54 | 55 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 56 | # the I18n.default_locale when a translation cannot be found). 57 | config.i18n.fallbacks = true 58 | 59 | # Send deprecation notices to registered listeners. 60 | config.active_support.deprecation = :notify 61 | 62 | # Use default logging formatter so that PID and timestamp are not suppressed. 63 | config.log_formatter = ::Logger::Formatter.new 64 | 65 | # Use a different logger for distributed setups. 66 | # require 'syslog/logger' 67 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 68 | 69 | if ENV["RAILS_LOG_TO_STDOUT"].present? 70 | logger = ActiveSupport::Logger.new(STDOUT) 71 | logger.formatter = config.log_formatter 72 | config.logger = ActiveSupport::TaggedLogging.new(logger) 73 | end 74 | 75 | # Do not dump schema after migrations. 76 | config.active_record.dump_schema_after_migration = false 77 | end 78 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | 'Cache-Control' => "public, max-age=3600" 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | config.action_mailer.perform_caching = false 32 | 33 | # Tell Action Mailer not to deliver emails to the real world. 34 | # The :test delivery method accumulates sent emails in the 35 | # ActionMailer::Base.deliveries array. 36 | config.action_mailer.delivery_method = :test 37 | 38 | # Print deprecation notices to the stderr. 39 | config.active_support.deprecation = :stderr 40 | 41 | # Raises error for missing translations 42 | # config.action_view.raise_on_missing_translations = true 43 | end 44 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/cors.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Avoid CORS issues when API is called from the frontend app. 4 | # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. 5 | 6 | # Read more: https://github.com/cyu/rack-cors 7 | 8 | # Rails.application.config.middleware.insert_before 0, Rack::Cors do 9 | # allow do 10 | # origins 'example.com' 11 | # 12 | # resource '*', 13 | # headers: :any, 14 | # methods: [:get, :post, :put, :patch, :delete, :options, :head] 15 | # end 16 | # end 17 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | # :nocov: 8 | ActiveSupport.on_load(:action_controller) do 9 | wrap_parameters format: [:json] 10 | end 11 | # :nocov: 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. 30 | # 31 | # preload_app! 32 | 33 | # Allow puma to be restarted by `rails restart` command. 34 | plugin :tmp_restart 35 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount Correspondent::Engine => "/correspondent" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/config/spring.rb: -------------------------------------------------------------------------------- 1 | %w[ 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ].each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20190226001839_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :users do |t| 4 | t.string :name 5 | t.string :email 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20190226002057_create_purchases.rb: -------------------------------------------------------------------------------- 1 | class CreatePurchases < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :purchases do |t| 4 | t.string :name 5 | t.references :user 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20190226182148_create_correspondent_notifications.rb: -------------------------------------------------------------------------------- 1 | class CreateCorrespondentNotifications < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :correspondent_notifications do |t| 4 | t.string :title 5 | t.string :content 6 | t.string :image_url 7 | t.boolean :dismissed, default: false 8 | t.string :publisher_type, null: false 9 | t.integer :publisher_id, null: false 10 | t.string :subscriber_type, null: false 11 | t.integer :subscriber_id, null: false 12 | t.index [:publisher_type, :publisher_id], name: "index_correspondent_on_publisher" 13 | t.index [:subscriber_type, :subscriber_id], name: "index_correspondent_on_subscriber" 14 | t.timestamps 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20190226212733_create_promotions.rb: -------------------------------------------------------------------------------- 1 | class CreatePromotions < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :promotions do |t| 4 | t.string :name 5 | end 6 | 7 | create_join_table :promotions, :users do |t| 8 | t.index [:promotion_id, :user_id] 9 | t.index [:user_id, :promotion_id] 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20190228192922_add_link_url_to_notifications.rb: -------------------------------------------------------------------------------- 1 | class AddLinkUrlToNotifications < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column(:correspondent_notifications, :link_url, :string) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20190307223149_create_stores.rb: -------------------------------------------------------------------------------- 1 | class CreateStores < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :stores do |t| 4 | t.string :name 5 | end 6 | 7 | change_table :purchases do |t| 8 | t.references :store 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20190307224854_add_referrer_url_to_notifications.rb: -------------------------------------------------------------------------------- 1 | class AddReferrerUrlToNotifications < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column(:correspondent_notifications, :referrer_url, :string) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `rails 6 | # db:schema:load`. When creating a new database, `rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2019_03_07_224854) do 14 | 15 | create_table "correspondent_notifications", force: :cascade do |t| 16 | t.string "title" 17 | t.string "content" 18 | t.string "image_url" 19 | t.boolean "dismissed", default: false 20 | t.string "publisher_type", null: false 21 | t.integer "publisher_id", null: false 22 | t.string "subscriber_type", null: false 23 | t.integer "subscriber_id", null: false 24 | t.datetime "created_at", null: false 25 | t.datetime "updated_at", null: false 26 | t.string "link_url" 27 | t.string "referrer_url" 28 | t.index ["publisher_type", "publisher_id"], name: "index_correspondent_on_publisher" 29 | t.index ["subscriber_type", "subscriber_id"], name: "index_correspondent_on_subscriber" 30 | end 31 | 32 | create_table "promotions", force: :cascade do |t| 33 | t.string "name" 34 | end 35 | 36 | create_table "promotions_users", id: false, force: :cascade do |t| 37 | t.integer "promotion_id", null: false 38 | t.integer "user_id", null: false 39 | t.index ["promotion_id", "user_id"], name: "index_promotions_users_on_promotion_id_and_user_id" 40 | t.index ["user_id", "promotion_id"], name: "index_promotions_users_on_user_id_and_promotion_id" 41 | end 42 | 43 | create_table "purchases", force: :cascade do |t| 44 | t.string "name" 45 | t.integer "user_id" 46 | t.datetime "created_at", null: false 47 | t.datetime "updated_at", null: false 48 | t.integer "store_id" 49 | t.index ["store_id"], name: "index_purchases_on_store_id" 50 | t.index ["user_id"], name: "index_purchases_on_user_id" 51 | end 52 | 53 | create_table "stores", force: :cascade do |t| 54 | t.string "name" 55 | end 56 | 57 | create_table "users", force: :cascade do |t| 58 | t.string "name" 59 | t.string "email" 60 | t.datetime "created_at", null: false 61 | t.datetime "updated_at", null: false 62 | end 63 | 64 | end 65 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinistock/correspondent/a4c6c5ff2d13ff4f94deb4eacf57d6ee076a4130/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/models/notification_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module Correspondent 6 | class NotificationTest < ActiveSupport::TestCase 7 | def setup 8 | @subscriber = User.create!(name: "user", email: "user@email.com") 9 | store = Store.create!(name: "best buy") 10 | @publisher = Purchase.create!(name: "purchase", user: @subscriber, store: store) 11 | end 12 | 13 | test "accepts polymorphic association for publisher and subscriber" do 14 | notification = Notification.create!(publisher: @publisher, subscriber: @subscriber) 15 | 16 | assert notification.valid? 17 | assert_equal @publisher, notification.publisher 18 | assert_equal @subscriber, notification.subscriber 19 | assert_includes @subscriber.notifications, notification 20 | assert_includes @publisher.notifications, notification 21 | end 22 | 23 | test ".create_for! single record" do 24 | data = { instance: @publisher, entity: :user, trigger: :purchase } 25 | notification = Correspondent::Notification.create_for!(data) 26 | 27 | assert_not notification.respond_to?(:each) 28 | assert notification.is_a?(Correspondent::Notification) 29 | assert_equal @publisher, notification.publisher 30 | assert_equal @subscriber, notification.subscriber 31 | assert_equal "Purchase ##{@publisher.id} for user user", notification.title 32 | assert_equal "Congratulations on your recent purchase of purchase", notification.content 33 | assert_equal "/purchases/#{@publisher.id}", notification.link_url 34 | assert_equal "/stores/#{@publisher.store.id}", notification.referrer_url 35 | end 36 | 37 | test ".create_for! multiple records" do 38 | users = [ 39 | User.create!(name: "user", email: "user@email.com"), 40 | User.create!(name: "user2", email: "user2@email.com") 41 | ] 42 | 43 | promotion = Promotion.create!(users: users, name: "promo") 44 | data = { instance: promotion, entity: :users, trigger: :promote } 45 | Correspondent::Notification.create_for!(data) 46 | 47 | assert_equal 2, Correspondent::Notification.count 48 | 49 | assert_equal "Promotion ##{promotion.id} - promo", Correspondent::Notification.first.title 50 | assert_equal "promo is coming to you this spring", Correspondent::Notification.first.content 51 | 52 | Correspondent::Notification.create_for!(data, avoid_duplicates: true) 53 | assert_equal 2, Correspondent::Notification.count 54 | end 55 | 56 | test ".create_for! with avoid duplicates" do 57 | data = { instance: @publisher, entity: :user, trigger: :purchase } 58 | notification = Correspondent::Notification.create_for!(data) 59 | assert notification.is_a?(Correspondent::Notification) 60 | 61 | notification = Correspondent::Notification.create_for!(data, avoid_duplicates: true) 62 | assert_nil notification 63 | end 64 | 65 | test ".by_parents" do 66 | data = { instance: @publisher, entity: :user, trigger: :purchase } 67 | notification = Correspondent::Notification.create_for!(data) 68 | 69 | assert_includes Correspondent::Notification.by_parents(@subscriber, @publisher), notification 70 | end 71 | 72 | test "#as_json" do 73 | data = { instance: @publisher, entity: :user, trigger: :purchase } 74 | notification = Correspondent::Notification.create_for!(data) 75 | 76 | Rails.cache.delete("correspondent_notification_#{notification.id}") 77 | 78 | assert_equal notification.attributes.except("updated_at", "subscriber_type", "subscriber_id"), 79 | notification.as_json 80 | end 81 | 82 | test ".for_subscriber" do 83 | data = { instance: @publisher, entity: :user, trigger: :purchase } 84 | notification = Correspondent::Notification.create_for!(data) 85 | assert_includes Correspondent::Notification.for_subscriber("user", @subscriber.id), notification 86 | end 87 | 88 | test "#dismiss!" do 89 | data = { instance: @publisher, entity: :user, trigger: :purchase } 90 | notification = Correspondent::Notification.create_for!(data) 91 | notification.dismiss! 92 | 93 | assert notification.dismissed 94 | end 95 | 96 | test ".not_dismissed" do 97 | data = { instance: @publisher, entity: :user, trigger: :purchase } 98 | notification = Correspondent::Notification.create_for!(data) 99 | notification2 = Correspondent::Notification.create_for!(data) 100 | notification.dismiss! 101 | 102 | collection = Correspondent::Notification.not_dismissed 103 | assert_includes collection, notification2 104 | assert_not_includes collection, notification 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] = "test" 4 | require_relative "../test/dummy/config/environment" 5 | require "simplecov" 6 | SimpleCov.start do 7 | add_filter("/test/") 8 | end 9 | 10 | ActiveRecord::Migrator.migrations_paths = [ 11 | File.expand_path("../test/dummy/db/migrate", __dir__), 12 | File.expand_path("../db/migrate", __dir__) 13 | ] 14 | 15 | require "rails/test_help" 16 | require "minitest/mock" 17 | require "purdytest" 18 | 19 | Minitest.backtrace_filter = Minitest::BacktraceFilter.new 20 | 21 | # Load fixtures from the engine 22 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 23 | ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) 24 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 25 | ActiveSupport::TestCase.file_fixture_path = "#{ActiveSupport::TestCase.fixture_path}/files" 26 | ActiveSupport::TestCase.fixtures :all 27 | end 28 | 29 | module ActiveSupport 30 | class TestCase 31 | parallelize(workers: :number_of_processors) 32 | 33 | def setup 34 | ApplicationMailer.deliveries = [] 35 | end 36 | end 37 | end 38 | 39 | # how_many_times_slower 40 | # 41 | # Traps $stdout into a StringIO 42 | # object to extract how many times 43 | # slower a method is in an IPS comparison. 44 | def how_many_times_slower 45 | benchmark = StringIO.new 46 | original_stdout = $stdout 47 | $stdout = benchmark 48 | 49 | yield 50 | 51 | $stdout = original_stdout 52 | benchmark.string.scan(/(?<=- )[\d.]+(?=x\s+slower)/).first.to_f 53 | end 54 | 55 | # average_exec_time 56 | # 57 | # Calculate average execution time 58 | # for a given block. 59 | def average_exec_time(&block) 60 | (0...1000).sum do 61 | Benchmark.measure(&block).real 62 | end / 1000 63 | end 64 | --------------------------------------------------------------------------------