├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue_template.md ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── publish_gem.yml ├── .gitignore ├── .standard.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── UPGRADE.md ├── app ├── jobs │ ├── .keep │ └── noticed │ │ ├── application_job.rb │ │ └── event_job.rb └── models │ ├── .keep │ ├── concerns │ ├── .keep │ └── noticed │ │ ├── deliverable.rb │ │ ├── notification_methods.rb │ │ └── readable.rb │ └── noticed │ ├── application_record.rb │ ├── deliverable │ └── deliver_by.rb │ ├── ephemeral.rb │ ├── event.rb │ └── notification.rb ├── bin ├── rails └── test ├── db └── migrate │ ├── 20231215190233_create_noticed_tables.rb │ └── 20240129184740_add_notifications_count_to_noticed_event.rb ├── docs ├── bulk_delivery_methods │ ├── bluesky.md │ ├── discord.md │ ├── slack.md │ └── webhook.md ├── delivery_methods │ ├── action_cable.md │ ├── discord.md │ ├── email.md │ ├── fcm.md │ ├── ios.md │ ├── microsoft_teams.md │ ├── slack.md │ ├── test.md │ ├── twilio_messaging.md │ ├── vonage.md │ └── vonage_sms.md ├── extending-noticed.md └── images │ ├── fcm-credentials-json.png │ └── fcm-project-settings.png ├── gemfiles ├── rails_6_1.gemfile ├── rails_6_1.gemfile.lock ├── rails_7_0.gemfile ├── rails_7_0.gemfile.lock ├── rails_7_1.gemfile ├── rails_7_1.gemfile.lock ├── rails_7_2.gemfile ├── rails_7_2.gemfile.lock ├── rails_8_0.gemfile ├── rails_8_0.gemfile.lock ├── rails_main.gemfile └── rails_main.gemfile.lock ├── lib ├── generators │ └── noticed │ │ ├── delivery_method_generator.rb │ │ ├── install_generator.rb │ │ ├── notifier_generator.rb │ │ └── templates │ │ ├── README │ │ ├── application_delivery_method.rb.tt │ │ ├── application_notifier.rb.tt │ │ ├── delivery_method.rb.tt │ │ └── notifier.rb.tt ├── noticed.rb └── noticed │ ├── api_client.rb │ ├── bulk_delivery_method.rb │ ├── bulk_delivery_methods │ ├── bluesky.rb │ ├── discord.rb │ ├── slack.rb │ ├── test.rb │ └── webhook.rb │ ├── coder.rb │ ├── delivery_method.rb │ ├── delivery_methods │ ├── action_cable.rb │ ├── discord.rb │ ├── email.rb │ ├── fcm.rb │ ├── ios.rb │ ├── microsoft_teams.rb │ ├── slack.rb │ ├── test.rb │ ├── twilio_messaging.rb │ ├── vonage_sms.rb │ └── webhook.rb │ ├── engine.rb │ ├── has_notifications.rb │ ├── notification_channel.rb │ ├── required_options.rb │ ├── translation.rb │ └── version.rb ├── noticed.gemspec └── test ├── bulk_delivery_methods └── webhook_test.rb ├── delivery_method_test.rb ├── delivery_methods ├── action_cable_test.rb ├── email_test.rb ├── fcm_test.rb ├── ios_test.rb ├── microsoft_teams_test.rb ├── slack_test.rb ├── twilio_messaging_test.rb ├── vonage_sms_test.rb └── webhook_test.rb ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ ├── application_mailer.rb │ │ └── user_mailer.rb │ ├── models │ │ ├── account.rb │ │ ├── admin.rb │ │ ├── application_record.rb │ │ ├── concerns │ │ │ └── .keep │ │ └── user.rb │ ├── notifiers │ │ ├── application_delivery_method.rb │ │ ├── application_notifier.rb │ │ ├── bulk_notifier.rb │ │ ├── comment_notifier.rb │ │ ├── deprecated_notifier.rb │ │ ├── ephemeral_notifier.rb │ │ ├── inherited_notifier.rb │ │ ├── priority_notifier.rb │ │ ├── queue_notifier.rb │ │ ├── receipt_notifier.rb │ │ ├── record_notifier.rb │ │ ├── simple_notifier.rb │ │ ├── test_notifier.rb │ │ ├── wait_notifier.rb │ │ └── wait_until_notifier.rb │ └── views │ │ └── layouts │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb ├── bin │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── assets.rb │ │ ├── content_security_policy.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ └── permissions_policy.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ └── storage.yml ├── db │ ├── migrate │ │ ├── 20231215202921_create_users.rb │ │ └── 20231215202924_create_accounts.rb │ └── schema.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep └── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ └── favicon.ico ├── ephemeral_notifier_test.rb ├── fixtures ├── accounts.yml ├── files │ └── .keep ├── noticed │ ├── events.yml │ └── notifications.yml └── users.yml ├── has_notifications_test.rb ├── jobs └── event_job_test.rb ├── models ├── .keep └── noticed │ ├── deliverable │ └── deliver_by_test.rb │ ├── event_test.rb │ └── notification_test.rb ├── noticed_test.rb ├── notifier_test.rb ├── test_helper.rb └── translation_test.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [excid3] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Get Help 4 | url: https://github.com/excid3/noticed/discussions/new?category=help 5 | about: If you can't get something to work the way you expect, open a question in our discussion forums. 6 | - name: Feature Request 7 | url: https://github.com/excid3/noticed/discussions/new?category=ideas 8 | about: 'Suggest any ideas you have using our discussion forums.' 9 | - name: Bug Report 10 | url: https://github.com/excid3/noticed/issues/new?body=%3C%21--%20Please%20provide%20all%20of%20the%20information%20requested%20below.%20We%27re%20a%20small%20team%20and%20without%20all%20of%20this%20information%20it%27s%20not%20possible%20for%20us%20to%20help%20and%20your%20bug%20report%20will%20be%20closed.%20--%3E%0A%0A%2A%2AWhat%20version%20of%20Noticed%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v2.0.4%0A%0A%2A%2AWhat%20version%20of%20Rails%20are%20you%20using%3F%2A%2A%0A%0AFor%20example%3A%20v7.1.1%0A%0A%2A%2ADescribe%20your%20issue%2A%2A%0A%0ADescribe%20the%20problem%20you%27re%20seeing%2C%20any%20important%20steps%20to%20reproduce%20and%20what%20behavior%20you%20expect%20instead. 11 | about: If you've already asked for help with a problem and confirmed something is broken with Noticed itself, create a bug report. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug 3 | about: File a bug/issue 4 | title: '[BUG] ' 5 | labels: Bug, Needs Triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Bug Report 11 | 12 | **Describe the Bug:** 13 | <!-- A clear and concise description of the bug --> 14 | 15 | **To Reproduce:** 16 | <!-- Steps to reproduce the behavior --> 17 | 18 | 1. Step 1 19 | 2. Step 2 20 | 3. ... 21 | 22 | **Expected Behavior:** 23 | <!-- A clear and concise description of what you expected to happen --> 24 | 25 | **Actual Behavior:** 26 | <!-- A clear and concise description of what actually happened --> 27 | 28 | **Screenshots (if applicable):** 29 | <!-- If applicable, add screenshots to help explain your problem --> 30 | 31 | **Environment:** 32 | - Noticed gem version: <!-- Specify the version of the Noticed gem where the bug occurred --> 33 | - Ruby version: <!-- Specify the version of Ruby you are using --> 34 | - Rails version: <!-- Specify the version of Rails you are using --> 35 | - Operating System: <!-- Specify your operating system --> 36 | 37 | **Additional Context:** 38 | <!-- Add any other context about the problem here --> 39 | 40 | **Possible Fix:** 41 | <!-- If you have suggestions on how to fix the bug, you can provide them here --> 42 | 43 | **Steps to Reproduce with Fix (if available):** 44 | <!-- If you have a fix, outline the steps to reproduce the bug using your fix --> 45 | 46 | **Related Issues:** 47 | <!-- If applicable, reference any related GitHub issues or pull requests --> 48 | 49 | **Labels to Apply:** 50 | <!-- Suggest labels that should be applied to this issue --> 51 | 52 | **Checklist:** 53 | <!-- Make sure all of these items are completed before submitting the issue --> 54 | 55 | - [ ] I have searched for similar issues and couldn't find any 56 | - [ ] I have checked the documentation for relevant information 57 | - [ ] I have included all the required information -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Pull Request 2 | 3 | **Summary:** 4 | <!-- Provide a brief summary of the changes in this pull request --> 5 | 6 | **Related Issue:** 7 | <!-- If applicable, reference the GitHub issue that this pull request resolves --> 8 | 9 | **Description:** 10 | <!-- Elaborate on the changes made in this pull request. What motivated these changes? --> 11 | 12 | **Testing:** 13 | <!-- Describe the steps you've taken to test the changes. Include relevant information for other contributors to verify the modifications --> 14 | 15 | **Screenshots (if applicable):** 16 | <!-- Include any relevant screenshots or GIFs that demonstrate the changes --> 17 | 18 | **Checklist:** 19 | <!-- Make sure all of these items are completed before submitting the pull request --> 20 | 21 | - [ ] Code follows the project's coding standards 22 | - [ ] Tests have been added or updated to cover the changes 23 | - [ ] Documentation has been updated (if applicable) 24 | - [ ] All existing tests pass 25 | - [ ] Conforms to the contributing guidelines 26 | 27 | **Additional Notes:** 28 | <!-- Any additional information or notes for the reviewers --> -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - main 10 | workflow_call: 11 | 12 | jobs: 13 | sqlite: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | ruby: ['3.2', '3.3', '3.4'] 18 | gemfile: 19 | - rails_6_1 20 | - rails_7_0 21 | - rails_7_1 22 | - rails_7_2 23 | - rails_8_0 24 | - rails_main 25 | exclude: 26 | # sqlite3 ~> 1.7 is not compatible with Ruby 3.4+ 27 | - gemfile: rails_6_1 28 | ruby: '3.4' 29 | - gemfile: rails_7_0 30 | ruby: '3.4' 31 | - gemfile: rails_7_1 32 | ruby: '3.4' 33 | - gemfile: rails_7_2 34 | ruby: '3.4' 35 | 36 | env: 37 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 38 | BUNDLE_PATH_RELATIVE_TO_CWD: true 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - name: Set up Ruby 44 | uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{ matrix.ruby }} 47 | bundler: default 48 | bundler-cache: true 49 | rubygems: latest 50 | 51 | - name: StandardRb check 52 | run: bundle exec standardrb 53 | 54 | - name: Run tests 55 | env: 56 | DATABASE_URL: "sqlite3:noticed_test" 57 | RAILS_ENV: test 58 | run: | 59 | bundle exec rails db:test:prepare 60 | bundle exec rails test 61 | 62 | mysql: 63 | runs-on: ubuntu-latest 64 | strategy: 65 | matrix: 66 | ruby: ['3.2', '3.3', '3.4'] 67 | gemfile: 68 | - rails_6_1 69 | - rails_7_0 70 | - rails_7_1 71 | - rails_7_2 72 | - rails_8_0 73 | - rails_main 74 | exclude: 75 | # sqlite3 ~> 1.7 is not compatible with Ruby 3.4+ 76 | - gemfile: rails_6_1 77 | ruby: '3.4' 78 | - gemfile: rails_7_0 79 | ruby: '3.4' 80 | - gemfile: rails_7_1 81 | ruby: '3.4' 82 | - gemfile: rails_7_2 83 | ruby: '3.4' 84 | 85 | env: 86 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 87 | BUNDLE_PATH_RELATIVE_TO_CWD: true 88 | 89 | services: 90 | mysql: 91 | image: mysql:8 92 | env: 93 | MYSQL_ALLOW_EMPTY_PASSWORD: true 94 | MYSQL_DATABASE: test 95 | ports: ['3306:3306'] 96 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 97 | 98 | steps: 99 | - uses: actions/checkout@v4 100 | 101 | - name: Set up Ruby 102 | uses: ruby/setup-ruby@v1 103 | with: 104 | ruby-version: ${{ matrix.ruby }} 105 | bundler: default 106 | bundler-cache: true 107 | rubygems: latest 108 | 109 | - name: StandardRb check 110 | run: bundle exec standardrb 111 | 112 | - name: Run tests 113 | env: 114 | DATABASE_URL: trilogy://root:@127.0.0.1:3306/test 115 | RAILS_ENV: test 116 | run: | 117 | bundle exec rails db:test:prepare 118 | bundle exec rails test 119 | 120 | postgres: 121 | runs-on: ubuntu-latest 122 | strategy: 123 | matrix: 124 | ruby: ['3.2', '3.3', '3.4'] 125 | gemfile: 126 | - rails_6_1 127 | - rails_7_0 128 | - rails_7_1 129 | - rails_7_2 130 | - rails_8_0 131 | - rails_main 132 | exclude: 133 | # sqlite3 ~> 1.7 is not compatible with Ruby 3.4+ 134 | - gemfile: rails_6_1 135 | ruby: '3.4' 136 | - gemfile: rails_7_0 137 | ruby: '3.4' 138 | - gemfile: rails_7_1 139 | ruby: '3.4' 140 | - gemfile: rails_7_2 141 | ruby: '3.4' 142 | 143 | env: 144 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 145 | BUNDLE_PATH_RELATIVE_TO_CWD: true 146 | 147 | services: 148 | postgres: 149 | image: postgres:16 150 | env: 151 | POSTGRES_USER: postgres 152 | POSTGRES_PASSWORD: password 153 | POSTGRES_DB: test 154 | ports: ['5432:5432'] 155 | 156 | steps: 157 | - uses: actions/checkout@v4 158 | 159 | - name: Set up Ruby 160 | uses: ruby/setup-ruby@v1 161 | with: 162 | ruby-version: ${{ matrix.ruby }} 163 | bundler: default 164 | bundler-cache: true 165 | rubygems: latest 166 | 167 | - name: StandardRb check 168 | run: bundle exec standardrb 169 | 170 | - name: Run tests 171 | env: 172 | DATABASE_URL: postgres://postgres:password@localhost:5432/test 173 | RAILS_ENV: test 174 | run: | 175 | bundle exec rails db:test:prepare 176 | bundle exec rails test 177 | -------------------------------------------------------------------------------- /.github/workflows/publish_gem.yml: -------------------------------------------------------------------------------- 1 | name: Publish Gem 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: "Version" 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | test: 12 | uses: ./.github/workflows/ci.yml 13 | 14 | push: 15 | needs: test 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | contents: write 20 | id-token: write 21 | 22 | steps: 23 | # Set up 24 | - uses: actions/checkout@v4 25 | - name: Set up Ruby 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | bundler-cache: true 29 | ruby-version: ruby 30 | 31 | - name: Update version 32 | run: | 33 | sed -i 's/".*"/"${{ inputs.version }}"/' lib/noticed/version.rb 34 | bundle config set --local deployment 'false' 35 | bundle 36 | bundle exec appraisal 37 | git config user.name 'GitHub Actions' 38 | git config user.email github-actions@github.com 39 | git add Gemfile.lock gemfiles lib 40 | git commit -m "Version bump" 41 | git push 42 | 43 | # Release 44 | - uses: rubygems/release-gem@v1 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | pkg/ 4 | test/dummy/db/*.sqlite3 5 | test/dummy/db/*.sqlite3-journal 6 | test/dummy/db/*.sqlite3-* 7 | test/dummy/log/*.log 8 | test/dummy/storage/ 9 | test/dummy/tmp/ 10 | .byebug_history 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.0 2 | ignore: 3 | - '**/*': 4 | - Style/HashSyntax 5 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-6-1" do 2 | gem "rails", "~> 6.1.0" 3 | gem "sqlite3", "~> 1.7" 4 | gem "activerecord-trilogy-adapter" 5 | 6 | # Ruby 3.4 drops these default gems 7 | gem "bigdecimal" 8 | gem "drb" 9 | gem "mutex_m" 10 | 11 | # Fixes uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError) 12 | gem "concurrent-ruby", "< 1.3.5" 13 | end 14 | 15 | appraise "rails-7-0" do 16 | gem "rails", "~> 7.0.0" 17 | gem "sqlite3", "~> 1.7" 18 | gem "activerecord-trilogy-adapter" 19 | 20 | # Ruby 3.4 drops these default gems 21 | gem "bigdecimal" 22 | gem "drb" 23 | gem "mutex_m" 24 | 25 | # Fixes uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError) 26 | gem "concurrent-ruby", "< 1.3.5" 27 | end 28 | 29 | appraise "rails-7-1" do 30 | gem "rails", "~> 7.1.0" 31 | gem "sqlite3", "~> 1.7" 32 | gem "trilogy" 33 | end 34 | 35 | appraise "rails-7-2" do 36 | gem "rails", "~> 7.2.0" 37 | gem "sqlite3", "~> 1.7" 38 | gem "trilogy" 39 | end 40 | 41 | appraise "rails-8-0" do 42 | gem "rails", "~> 8.0.0" 43 | gem "sqlite3", "~> 2.0" 44 | gem "trilogy" 45 | end 46 | 47 | appraise "rails-main" do 48 | gem "rails", github: "rails/rails", branch: "main" 49 | gem "sqlite3", "~> 2.0" 50 | gem "trilogy" 51 | end 52 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Declare your gem's dependencies in noticed.gemspec. 5 | # Bundler will treat runtime dependencies like base dependencies, and 6 | # development dependencies will be added by default to the :development group. 7 | gemspec 8 | 9 | # Declare any dependencies that are still in development here instead of in 10 | # your gemspec. These might include edge Rails or gems from your path or 11 | # Git. Remember to move these dependencies to your gemspec before releasing 12 | # your gem to rubygems.org. 13 | 14 | gem "appraisal" 15 | gem "pg" 16 | gem "sqlite3" 17 | gem "standard" 18 | gem "webmock" 19 | 20 | # iOS notifications 21 | gem "apnotic", "~> 1.7" 22 | 23 | # firebase notifications 24 | gem "googleauth", "~> 1.1" 25 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | noticed (2.7.0) 5 | rails (>= 6.1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (8.0.1) 11 | actionpack (= 8.0.1) 12 | activesupport (= 8.0.1) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | zeitwerk (~> 2.6) 16 | actionmailbox (8.0.1) 17 | actionpack (= 8.0.1) 18 | activejob (= 8.0.1) 19 | activerecord (= 8.0.1) 20 | activestorage (= 8.0.1) 21 | activesupport (= 8.0.1) 22 | mail (>= 2.8.0) 23 | actionmailer (8.0.1) 24 | actionpack (= 8.0.1) 25 | actionview (= 8.0.1) 26 | activejob (= 8.0.1) 27 | activesupport (= 8.0.1) 28 | mail (>= 2.8.0) 29 | rails-dom-testing (~> 2.2) 30 | actionpack (8.0.1) 31 | actionview (= 8.0.1) 32 | activesupport (= 8.0.1) 33 | nokogiri (>= 1.8.5) 34 | rack (>= 2.2.4) 35 | rack-session (>= 1.0.1) 36 | rack-test (>= 0.6.3) 37 | rails-dom-testing (~> 2.2) 38 | rails-html-sanitizer (~> 1.6) 39 | useragent (~> 0.16) 40 | actiontext (8.0.1) 41 | actionpack (= 8.0.1) 42 | activerecord (= 8.0.1) 43 | activestorage (= 8.0.1) 44 | activesupport (= 8.0.1) 45 | globalid (>= 0.6.0) 46 | nokogiri (>= 1.8.5) 47 | actionview (8.0.1) 48 | activesupport (= 8.0.1) 49 | builder (~> 3.1) 50 | erubi (~> 1.11) 51 | rails-dom-testing (~> 2.2) 52 | rails-html-sanitizer (~> 1.6) 53 | activejob (8.0.1) 54 | activesupport (= 8.0.1) 55 | globalid (>= 0.3.6) 56 | activemodel (8.0.1) 57 | activesupport (= 8.0.1) 58 | activerecord (8.0.1) 59 | activemodel (= 8.0.1) 60 | activesupport (= 8.0.1) 61 | timeout (>= 0.4.0) 62 | activestorage (8.0.1) 63 | actionpack (= 8.0.1) 64 | activejob (= 8.0.1) 65 | activerecord (= 8.0.1) 66 | activesupport (= 8.0.1) 67 | marcel (~> 1.0) 68 | activesupport (8.0.1) 69 | base64 70 | benchmark (>= 0.3) 71 | bigdecimal 72 | concurrent-ruby (~> 1.0, >= 1.3.1) 73 | connection_pool (>= 2.2.5) 74 | drb 75 | i18n (>= 1.6, < 2) 76 | logger (>= 1.4.2) 77 | minitest (>= 5.1) 78 | securerandom (>= 0.3) 79 | tzinfo (~> 2.0, >= 2.0.5) 80 | uri (>= 0.13.1) 81 | addressable (2.8.7) 82 | public_suffix (>= 2.0.2, < 7.0) 83 | apnotic (1.7.2) 84 | connection_pool (~> 2) 85 | net-http2 (>= 0.18.3, < 2) 86 | appraisal (2.5.0) 87 | bundler 88 | rake 89 | thor (>= 0.14.0) 90 | ast (2.4.2) 91 | base64 (0.2.0) 92 | benchmark (0.4.0) 93 | bigdecimal (3.1.9) 94 | builder (3.3.0) 95 | concurrent-ruby (1.3.5) 96 | connection_pool (2.5.0) 97 | crack (1.0.0) 98 | bigdecimal 99 | rexml 100 | crass (1.0.6) 101 | date (3.4.1) 102 | drb (2.2.1) 103 | erubi (1.13.1) 104 | faraday (2.12.2) 105 | faraday-net_http (>= 2.0, < 3.5) 106 | json 107 | logger 108 | faraday-net_http (3.4.0) 109 | net-http (>= 0.5.0) 110 | globalid (1.2.1) 111 | activesupport (>= 6.1) 112 | google-cloud-env (2.2.1) 113 | faraday (>= 1.0, < 3.a) 114 | google-logging-utils (0.1.0) 115 | googleauth (1.12.2) 116 | faraday (>= 1.0, < 3.a) 117 | google-cloud-env (~> 2.2) 118 | google-logging-utils (~> 0.1) 119 | jwt (>= 1.4, < 3.0) 120 | multi_json (~> 1.11) 121 | os (>= 0.9, < 2.0) 122 | signet (>= 0.16, < 2.a) 123 | hashdiff (1.1.2) 124 | http-2 (0.12.0) 125 | base64 126 | i18n (1.14.6) 127 | concurrent-ruby (~> 1.0) 128 | io-console (0.8.0) 129 | irb (1.14.3) 130 | rdoc (>= 4.0.0) 131 | reline (>= 0.4.2) 132 | json (2.9.1) 133 | jwt (2.10.1) 134 | base64 135 | language_server-protocol (3.17.0.3) 136 | lint_roller (1.1.0) 137 | logger (1.6.5) 138 | loofah (2.24.0) 139 | crass (~> 1.0.2) 140 | nokogiri (>= 1.12.0) 141 | mail (2.8.1) 142 | mini_mime (>= 0.1.1) 143 | net-imap 144 | net-pop 145 | net-smtp 146 | marcel (1.0.4) 147 | mini_mime (1.1.5) 148 | minitest (5.25.4) 149 | multi_json (1.15.0) 150 | net-http (0.6.0) 151 | uri 152 | net-http2 (0.18.5) 153 | http-2 (~> 0.11) 154 | net-imap (0.5.5) 155 | date 156 | net-protocol 157 | net-pop (0.1.2) 158 | net-protocol 159 | net-protocol (0.2.2) 160 | timeout 161 | net-smtp (0.5.0) 162 | net-protocol 163 | nio4r (2.7.4) 164 | nokogiri (1.18.1-aarch64-linux-gnu) 165 | racc (~> 1.4) 166 | nokogiri (1.18.1-aarch64-linux-musl) 167 | racc (~> 1.4) 168 | nokogiri (1.18.1-arm-linux-gnu) 169 | racc (~> 1.4) 170 | nokogiri (1.18.1-arm-linux-musl) 171 | racc (~> 1.4) 172 | nokogiri (1.18.1-arm64-darwin) 173 | racc (~> 1.4) 174 | nokogiri (1.18.1-x86_64-darwin) 175 | racc (~> 1.4) 176 | nokogiri (1.18.1-x86_64-linux-gnu) 177 | racc (~> 1.4) 178 | nokogiri (1.18.1-x86_64-linux-musl) 179 | racc (~> 1.4) 180 | os (1.1.4) 181 | parallel (1.26.3) 182 | parser (3.3.7.0) 183 | ast (~> 2.4.1) 184 | racc 185 | pg (1.5.9) 186 | psych (5.2.2) 187 | date 188 | stringio 189 | public_suffix (6.0.1) 190 | racc (1.8.1) 191 | rack (3.1.8) 192 | rack-session (2.1.0) 193 | base64 (>= 0.1.0) 194 | rack (>= 3.0.0) 195 | rack-test (2.2.0) 196 | rack (>= 1.3) 197 | rackup (2.2.1) 198 | rack (>= 3) 199 | rails (8.0.1) 200 | actioncable (= 8.0.1) 201 | actionmailbox (= 8.0.1) 202 | actionmailer (= 8.0.1) 203 | actionpack (= 8.0.1) 204 | actiontext (= 8.0.1) 205 | actionview (= 8.0.1) 206 | activejob (= 8.0.1) 207 | activemodel (= 8.0.1) 208 | activerecord (= 8.0.1) 209 | activestorage (= 8.0.1) 210 | activesupport (= 8.0.1) 211 | bundler (>= 1.15.0) 212 | railties (= 8.0.1) 213 | rails-dom-testing (2.2.0) 214 | activesupport (>= 5.0.0) 215 | minitest 216 | nokogiri (>= 1.6) 217 | rails-html-sanitizer (1.6.2) 218 | loofah (~> 2.21) 219 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 220 | railties (8.0.1) 221 | actionpack (= 8.0.1) 222 | activesupport (= 8.0.1) 223 | irb (~> 1.13) 224 | rackup (>= 1.0.0) 225 | rake (>= 12.2) 226 | thor (~> 1.0, >= 1.2.2) 227 | zeitwerk (~> 2.6) 228 | rainbow (3.1.1) 229 | rake (13.2.1) 230 | rdoc (6.11.0) 231 | psych (>= 4.0.0) 232 | regexp_parser (2.10.0) 233 | reline (0.6.0) 234 | io-console (~> 0.5) 235 | rexml (3.4.0) 236 | rubocop (1.70.0) 237 | json (~> 2.3) 238 | language_server-protocol (>= 3.17.0) 239 | parallel (~> 1.10) 240 | parser (>= 3.3.0.2) 241 | rainbow (>= 2.2.2, < 4.0) 242 | regexp_parser (>= 2.9.3, < 3.0) 243 | rubocop-ast (>= 1.36.2, < 2.0) 244 | ruby-progressbar (~> 1.7) 245 | unicode-display_width (>= 2.4.0, < 4.0) 246 | rubocop-ast (1.37.0) 247 | parser (>= 3.3.1.0) 248 | rubocop-performance (1.23.1) 249 | rubocop (>= 1.48.1, < 2.0) 250 | rubocop-ast (>= 1.31.1, < 2.0) 251 | ruby-progressbar (1.13.0) 252 | securerandom (0.4.1) 253 | signet (0.19.0) 254 | addressable (~> 2.8) 255 | faraday (>= 0.17.5, < 3.a) 256 | jwt (>= 1.5, < 3.0) 257 | multi_json (~> 1.10) 258 | sqlite3 (2.5.0-aarch64-linux-gnu) 259 | sqlite3 (2.5.0-aarch64-linux-musl) 260 | sqlite3 (2.5.0-arm-linux-gnu) 261 | sqlite3 (2.5.0-arm-linux-musl) 262 | sqlite3 (2.5.0-arm64-darwin) 263 | sqlite3 (2.5.0-x86_64-darwin) 264 | sqlite3 (2.5.0-x86_64-linux-gnu) 265 | sqlite3 (2.5.0-x86_64-linux-musl) 266 | standard (1.44.0) 267 | language_server-protocol (~> 3.17.0.2) 268 | lint_roller (~> 1.0) 269 | rubocop (~> 1.70.0) 270 | standard-custom (~> 1.0.0) 271 | standard-performance (~> 1.6) 272 | standard-custom (1.0.2) 273 | lint_roller (~> 1.0) 274 | rubocop (~> 1.50) 275 | standard-performance (1.6.0) 276 | lint_roller (~> 1.1) 277 | rubocop-performance (~> 1.23.0) 278 | stringio (3.1.2) 279 | thor (1.3.2) 280 | timeout (0.4.3) 281 | tzinfo (2.0.6) 282 | concurrent-ruby (~> 1.0) 283 | unicode-display_width (3.1.4) 284 | unicode-emoji (~> 4.0, >= 4.0.4) 285 | unicode-emoji (4.0.4) 286 | uri (1.0.2) 287 | useragent (0.16.11) 288 | webmock (3.24.0) 289 | addressable (>= 2.8.0) 290 | crack (>= 0.3.2) 291 | hashdiff (>= 0.4.0, < 2.0.0) 292 | websocket-driver (0.7.7) 293 | base64 294 | websocket-extensions (>= 0.1.0) 295 | websocket-extensions (0.1.5) 296 | zeitwerk (2.7.1) 297 | 298 | PLATFORMS 299 | aarch64-linux-gnu 300 | aarch64-linux-musl 301 | arm-linux-gnu 302 | arm-linux-musl 303 | arm64-darwin 304 | x86_64-darwin 305 | x86_64-linux 306 | x86_64-linux-gnu 307 | x86_64-linux-musl 308 | 309 | DEPENDENCIES 310 | apnotic (~> 1.7) 311 | appraisal 312 | googleauth (~> 1.1) 313 | noticed! 314 | pg 315 | sqlite3 316 | standard 317 | webmock 318 | 319 | BUNDLED WITH 320 | 2.6.8 321 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Chris Oliver 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 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require "bundler/setup" 3 | rescue LoadError 4 | puts "You must `gem install bundler` and `bundle install` to run rake tasks" 5 | end 6 | 7 | require "rdoc/task" 8 | 9 | RDoc::Task.new(:rdoc) do |rdoc| 10 | rdoc.rdoc_dir = "rdoc" 11 | rdoc.title = "Noticed" 12 | rdoc.options << "--line-numbers" 13 | rdoc.rdoc_files.include("README.md") 14 | rdoc.rdoc_files.include("lib/**/*.rb") 15 | end 16 | 17 | require "bundler/gem_tasks" 18 | 19 | require "rake/testtask" 20 | 21 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 22 | load "rails/tasks/engine.rake" 23 | load "rails/tasks/statistics.rake" 24 | 25 | Rake::TestTask.new(:test) do |t| 26 | t.libs << "test" 27 | t.pattern = "test/**/*_test.rb" 28 | t.verbose = false 29 | end 30 | 31 | task default: :test 32 | -------------------------------------------------------------------------------- /app/jobs/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/app/jobs/.keep -------------------------------------------------------------------------------- /app/jobs/noticed/application_job.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class ApplicationJob < ActiveJob::Base 3 | # Automatically retry jobs that encountered a deadlock 4 | # retry_on ActiveRecord::Deadlocked 5 | 6 | # Most jobs are safe to ignore if the underlying records are no longer available 7 | discard_on ActiveJob::DeserializationError 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/jobs/noticed/event_job.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class EventJob < Noticed.parent_class.constantize 3 | def perform(event) 4 | # Enqueue bulk deliveries 5 | event.bulk_delivery_methods.each_value do |deliver_by| 6 | deliver_by.perform_later(event) if deliver_by.perform?(event) 7 | end 8 | 9 | # Enqueue individual deliveries 10 | event.notifications.each do |notification| 11 | event.delivery_methods.each_value do |deliver_by| 12 | deliver_by.perform_later(notification) if deliver_by.perform?(notification) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/app/models/.keep -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/concerns/noticed/deliverable.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module Deliverable 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | class_attribute :bulk_delivery_methods, instance_writer: false, default: {} 7 | class_attribute :delivery_methods, instance_writer: false, default: {} 8 | class_attribute :required_param_names, instance_writer: false, default: [] 9 | class_attribute :_recipients, instance_writer: false 10 | end 11 | 12 | class_methods do 13 | def inherited(base) 14 | base.bulk_delivery_methods = bulk_delivery_methods.dup 15 | base.delivery_methods = delivery_methods.dup 16 | base.required_param_names = required_param_names.dup 17 | super 18 | end 19 | 20 | def bulk_deliver_by(name, options = {}) 21 | raise NameError, "#{name} has already been used for this Notifier." if bulk_delivery_methods.has_key?(name) 22 | 23 | config = ActiveSupport::OrderedOptions.new.merge(options) 24 | yield config if block_given? 25 | bulk_delivery_methods[name] = DeliverBy.new(name, config, bulk: true) 26 | end 27 | 28 | def deliver_by(name, options = {}) 29 | raise NameError, "#{name} has already been used for this Notifier." if delivery_methods.has_key?(name) 30 | 31 | if name == :database 32 | Noticed.deprecator.warn <<-WARNING.squish 33 | The :database delivery method has been deprecated and does nothing. Notifiers automatically save to the database now. 34 | WARNING 35 | return 36 | end 37 | 38 | config = ActiveSupport::OrderedOptions.new.merge(options) 39 | yield config if block_given? 40 | delivery_methods[name] = DeliverBy.new(name, config) 41 | end 42 | 43 | def recipients(option = nil, &block) 44 | self._recipients = block || option 45 | end 46 | 47 | def required_params(*names) 48 | required_param_names.concat names 49 | end 50 | alias_method :required_param, :required_params 51 | 52 | def params(*names) 53 | Noticed.deprecator.warn <<-WARNING.squish 54 | `params` is deprecated and has been renamed to `required_params` 55 | WARNING 56 | required_params(*names) 57 | end 58 | 59 | def param(*names) 60 | Noticed.deprecator.warn <<-WARNING.squish 61 | `param :name` is deprecated and has been renamed to `required_param :name` 62 | WARNING 63 | required_params(*names) 64 | end 65 | 66 | def with(params) 67 | if self < Ephemeral 68 | new(params: params) 69 | else 70 | record = params.delete(:record) 71 | new(params: params, record: record) 72 | end 73 | end 74 | 75 | def deliver(recipients = nil, **options) 76 | new.deliver(recipients, **options) 77 | end 78 | alias_method :deliver_later, :deliver 79 | end 80 | 81 | # CommentNotifier.deliver(User.all) 82 | # CommentNotifier.deliver(User.all, priority: 10) 83 | # CommentNotifier.deliver(User.all, queue: :low_priority) 84 | # CommentNotifier.deliver(User.all, wait: 5.minutes) 85 | # CommentNotifier.deliver(User.all, wait_until: 1.hour.from_now) 86 | def deliver(recipients = nil, enqueue_job: true, **options) 87 | recipients ||= evaluate_recipients 88 | 89 | validate! 90 | 91 | transaction do 92 | recipients_attributes = Array.wrap(recipients).map do |recipient| 93 | recipient_attributes_for(recipient) 94 | end 95 | 96 | self.notifications_count = recipients_attributes.size 97 | save! 98 | 99 | if Rails.gem_version >= Gem::Version.new("7.0.0.alpha1") 100 | notifications.insert_all!(recipients_attributes, record_timestamps: true) if recipients_attributes.any? 101 | else 102 | time = Time.current 103 | recipients_attributes.each do |attributes| 104 | attributes[:created_at] = time 105 | attributes[:updated_at] = time 106 | end 107 | notifications.insert_all!(recipients_attributes) if recipients_attributes.any? 108 | end 109 | end 110 | 111 | # Enqueue delivery job 112 | EventJob.set(options).perform_later(self) if enqueue_job 113 | 114 | self 115 | end 116 | alias_method :deliver_later, :deliver 117 | 118 | def evaluate_recipients 119 | return unless _recipients 120 | 121 | if _recipients.respond_to?(:call) 122 | instance_exec(&_recipients) 123 | elsif _recipients.is_a?(Symbol) && respond_to?(_recipients) 124 | send(_recipients) 125 | end 126 | end 127 | 128 | def recipient_attributes_for(recipient) 129 | { 130 | type: "#{self.class.name}::Notification", 131 | recipient_type: recipient.class.base_class.name, 132 | recipient_id: recipient.id 133 | } 134 | end 135 | 136 | def validate! 137 | validate_params! 138 | validate_delivery_methods! 139 | end 140 | 141 | def validate_params! 142 | required_param_names.each do |param_name| 143 | raise ValidationError, "Param `#{param_name}` is required for #{self.class.name}." unless params.has_key?(param_name) 144 | end 145 | end 146 | 147 | def validate_delivery_methods! 148 | bulk_delivery_methods.values.each(&:validate!) 149 | delivery_methods.values.each(&:validate!) 150 | end 151 | 152 | # If a GlobalID record in params is no longer found, the params will default with a noticed_error key 153 | def deserialize_error? 154 | !!params[:noticed_error] 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /app/models/concerns/noticed/notification_methods.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module NotificationMethods 3 | extend ActiveSupport::Concern 4 | 5 | class_methods do 6 | # Generate a Notification class each time a Notifier is defined 7 | def inherited(notifier) 8 | super 9 | notifier.const_set :Notification, Class.new(const_defined?(:Notification) ? const_get(:Notification) : Noticed::Notification) 10 | end 11 | 12 | def notification_methods(&block) 13 | const_get(:Notification).class_eval(&block) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/concerns/noticed/readable.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module Readable 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | scope :read, -> { where.not(read_at: nil) } 7 | scope :unread, -> { where(read_at: nil) } 8 | scope :seen, -> { where.not(seen_at: nil) } 9 | scope :unseen, -> { where(seen_at: nil) } 10 | end 11 | 12 | class_methods do 13 | def mark_as_read_and_seen(**kwargs) 14 | update_all(**kwargs.with_defaults(read_at: Time.current, seen_at: Time.current, updated_at: Time.current)) 15 | end 16 | 17 | def mark_as_unread_and_unseen(**kwargs) 18 | update_all(**kwargs.with_defaults(read_at: nil, seen_at: nil, updated_at: Time.current)) 19 | end 20 | 21 | def mark_as_read(**kwargs) 22 | update_all(**kwargs.with_defaults(read_at: Time.current, updated_at: Time.current)) 23 | end 24 | 25 | def mark_as_unread(**kwargs) 26 | update_all(**kwargs.with_defaults(read_at: nil, updated_at: Time.current)) 27 | end 28 | 29 | def mark_as_seen(**kwargs) 30 | update_all(**kwargs.with_defaults(seen_at: Time.current, updated_at: Time.current)) 31 | end 32 | 33 | def mark_as_unseen(**kwargs) 34 | update_all(**kwargs.with_defaults(seen_at: nil, updated_at: Time.current)) 35 | end 36 | end 37 | 38 | def mark_as_read 39 | update(read_at: Time.current) 40 | end 41 | 42 | def mark_as_read! 43 | update!(read_at: Time.current) 44 | end 45 | 46 | def mark_as_unread 47 | update(read_at: nil) 48 | end 49 | 50 | def mark_as_unread! 51 | update!(read_at: nil) 52 | end 53 | 54 | def mark_as_seen 55 | update(seen_at: Time.current) 56 | end 57 | 58 | def mark_as_seen! 59 | update!(seen_at: Time.current) 60 | end 61 | 62 | def mark_as_unseen 63 | update(seen_at: nil) 64 | end 65 | 66 | def mark_as_unseen! 67 | update!(seen_at: nil) 68 | end 69 | 70 | def read? 71 | read_at? 72 | end 73 | 74 | def unread? 75 | !read_at? 76 | end 77 | 78 | def seen? 79 | seen_at? 80 | end 81 | 82 | def unseen? 83 | !seen_at? 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /app/models/noticed/application_record.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | self.table_name_prefix = "noticed_" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/models/noticed/deliverable/deliver_by.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module Deliverable 3 | class DeliverBy 4 | attr_reader :name, :config, :bulk 5 | 6 | def initialize(name, config, bulk: false) 7 | @name, @config, @bulk, = name, config, bulk 8 | end 9 | 10 | def constant 11 | namespace = bulk ? "Noticed::BulkDeliveryMethods" : "Noticed::DeliveryMethods" 12 | config.fetch(:class, [namespace, name.to_s.camelize].join("::")).constantize 13 | end 14 | 15 | def validate! 16 | constant.required_option_names.each do |option| 17 | raise ValidationError, "option `#{option}` must be set for `deliver_by :#{name}`" unless config[option].present? 18 | end 19 | end 20 | 21 | def perform_later(event_or_notification, options = {}) 22 | constant.set(computed_options(options, event_or_notification)).perform_later(name, event_or_notification) 23 | end 24 | 25 | def ephemeral_perform_later(notifier, recipient, params, options = {}) 26 | constant.set(computed_options(options, recipient)) 27 | .perform_later(name, "#{notifier}::Notification", recipient: recipient, params: params) 28 | end 29 | 30 | def evaluate_option(name, context) 31 | option = config[name] 32 | 33 | if option.respond_to?(:call) 34 | context.instance_exec(&option) 35 | elsif option.is_a?(Symbol) && context.respond_to?(option, true) 36 | context.send(option) 37 | else 38 | option 39 | end 40 | end 41 | 42 | def perform?(notification) 43 | return true unless config.key?(:before_enqueue) 44 | 45 | perform = false 46 | catch(:abort) { 47 | evaluate_option(:before_enqueue, notification) 48 | perform = true 49 | } 50 | perform 51 | end 52 | 53 | private 54 | 55 | def computed_options(options, recipient) 56 | options[:wait] ||= evaluate_option(:wait, recipient) if config.has_key?(:wait) 57 | options[:wait_until] ||= evaluate_option(:wait_until, recipient) if config.has_key?(:wait_until) 58 | options[:queue] ||= evaluate_option(:queue, recipient) if config.has_key?(:queue) 59 | options[:priority] ||= evaluate_option(:priority, recipient) if config.has_key?(:priority) 60 | options 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /app/models/noticed/ephemeral.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class Ephemeral 3 | include ActiveModel::Model 4 | include ActiveModel::Attributes 5 | include Noticed::Deliverable 6 | include Noticed::Translation 7 | include Rails.application.routes.url_helpers 8 | 9 | attribute :record 10 | attribute :params, default: {} 11 | 12 | class Notification 13 | include ActiveModel::Model 14 | include ActiveModel::Attributes 15 | include Noticed::Translation 16 | include Rails.application.routes.url_helpers 17 | 18 | attribute :recipient 19 | attribute :event 20 | 21 | delegate :params, :record, to: :event 22 | 23 | def self.new_with_params(recipient, params) 24 | instance = new(recipient: recipient) 25 | instance.event = module_parent.new(params: params) 26 | instance 27 | end 28 | end 29 | 30 | # Dynamically define Notification on each Ephemeral Notifier 31 | def self.inherited(notifier) 32 | super 33 | notifier.const_set :Notification, Class.new(Noticed::Ephemeral::Notification) 34 | end 35 | 36 | def self.notification_methods(&block) 37 | const_get(:Notification).class_eval(&block) 38 | end 39 | 40 | def deliver(recipients = nil) 41 | recipients ||= evaluate_recipients 42 | recipients = Array.wrap(recipients) 43 | 44 | bulk_delivery_methods.each do |_, deliver_by| 45 | deliver_by.ephemeral_perform_later(self.class.name, recipients, params) 46 | end 47 | 48 | recipients.each do |recipient| 49 | delivery_methods.each do |_, deliver_by| 50 | deliver_by.ephemeral_perform_later(self.class.name, recipient, params) 51 | end 52 | end 53 | 54 | self 55 | end 56 | 57 | def record 58 | params[:record] 59 | end 60 | end 61 | end 62 | 63 | ActiveSupport.run_load_hooks :noticed_ephemeral, Noticed::Ephemeral 64 | -------------------------------------------------------------------------------- /app/models/noticed/event.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class Event < ApplicationRecord 3 | include Deliverable 4 | include NotificationMethods 5 | include Translation 6 | include Rails.application.routes.url_helpers 7 | 8 | belongs_to :record, polymorphic: true, optional: true 9 | has_many :notifications, dependent: :delete_all 10 | 11 | accepts_nested_attributes_for :notifications 12 | 13 | scope :newest_first, -> { order(created_at: :desc) } 14 | 15 | attribute :params, :json, default: {} 16 | 17 | # Ephemeral notifiers cannot serialize params since they aren't ActiveRecord backed 18 | if respond_to? :serialize 19 | if Rails.gem_version >= Gem::Version.new("7.1.0.alpha") 20 | serialize :params, coder: Coder 21 | else 22 | serialize :params, Coder 23 | end 24 | end 25 | end 26 | end 27 | 28 | ActiveSupport.run_load_hooks :noticed_event, Noticed::Event 29 | -------------------------------------------------------------------------------- /app/models/noticed/notification.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class Notification < ApplicationRecord 3 | include Rails.application.routes.url_helpers 4 | include Readable 5 | include Translation 6 | 7 | belongs_to :event, counter_cache: true 8 | belongs_to :recipient, polymorphic: true 9 | 10 | scope :newest_first, -> { order(created_at: :desc) } 11 | 12 | delegate :params, :record, to: :event 13 | end 14 | end 15 | 16 | ActiveSupport.run_load_hooks :noticed_notification, Noticed::Notification 17 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/noticed/engine", __dir__) 7 | APP_PATH = File.expand_path("../test/dummy/config/application", __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 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /db/migrate/20231215190233_create_noticed_tables.rb: -------------------------------------------------------------------------------- 1 | class CreateNoticedTables < ActiveRecord::Migration[6.1] 2 | def change 3 | primary_key_type, foreign_key_type = primary_and_foreign_key_types 4 | create_table :noticed_events, id: primary_key_type do |t| 5 | t.string :type 6 | t.belongs_to :record, polymorphic: true, type: foreign_key_type 7 | if t.respond_to?(:jsonb) 8 | t.jsonb :params 9 | else 10 | t.json :params 11 | end 12 | 13 | t.timestamps 14 | end 15 | 16 | create_table :noticed_notifications, id: primary_key_type do |t| 17 | t.string :type 18 | t.belongs_to :event, null: false, type: foreign_key_type 19 | t.belongs_to :recipient, polymorphic: true, null: false, type: foreign_key_type 20 | t.datetime :read_at 21 | t.datetime :seen_at 22 | 23 | t.timestamps 24 | end 25 | end 26 | 27 | private 28 | 29 | def primary_and_foreign_key_types 30 | config = Rails.configuration.generators 31 | setting = config.options[config.orm][:primary_key_type] 32 | primary_key_type = setting || :primary_key 33 | foreign_key_type = setting || :bigint 34 | [primary_key_type, foreign_key_type] 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /db/migrate/20240129184740_add_notifications_count_to_noticed_event.rb: -------------------------------------------------------------------------------- 1 | class AddNotificationsCountToNoticedEvent < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :noticed_events, :notifications_count, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /docs/bulk_delivery_methods/bluesky.md: -------------------------------------------------------------------------------- 1 | # Bluesky Bulk Delivery Method 2 | 3 | Create a Bluesky post. 4 | 5 | ## Usage 6 | 7 | ```ruby 8 | class CommentNotification 9 | bulk_deliver_by :bluesky do |config| 10 | config.identifier = "username" 11 | config.password = "password" 12 | config.json = -> { 13 | { 14 | text: "Hello world!", 15 | createdAt: Time.current.iso8601 16 | # ... 17 | } 18 | } 19 | end 20 | end 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/bulk_delivery_methods/discord.md: -------------------------------------------------------------------------------- 1 | # Discord Bulk Delivery Method 2 | 3 | Send a Discord message to builk notify users in a channel. 4 | 5 | We recommend using [Discohook](https://discohook.org) to design your messages. 6 | 7 | ## Usage 8 | 9 | ```ruby 10 | class CommentNotification 11 | bulk_deliver_by :discord do |config| 12 | config.url = "https://discord.com..." 13 | config.json = -> { 14 | { 15 | # ... 16 | } 17 | } 18 | end 19 | end 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/bulk_delivery_methods/slack.md: -------------------------------------------------------------------------------- 1 | # Slack Bulk Delivery Method 2 | 3 | Send a Slack message to bulk notify users in a channel. 4 | 5 | ## Usage 6 | 7 | ```ruby 8 | class CommentNotification 9 | bulk_deliver_by :slack do |config| 10 | config.url = "https://slack.com..." 11 | config.json = -> { 12 | { 13 | # ... 14 | } 15 | } 16 | 17 | # Slack's chat.postMessage endpoint returns a 200 with {ok: true/false}. Disable this check by setting to false 18 | # config.raise_if_not_ok = true 19 | end 20 | end 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/bulk_delivery_methods/webhook.md: -------------------------------------------------------------------------------- 1 | # Webhook Bulk Delivery Method 2 | 3 | Send a webhook request to bulk notify users in a channel. 4 | 5 | ## Usage 6 | 7 | ```ruby 8 | class CommentNotification 9 | bulk_deliver_by :webhook do |config| 10 | config.url = "https://example.org..." 11 | config.json = -> { 12 | { 13 | # ... 14 | } 15 | } 16 | end 17 | end 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/delivery_methods/action_cable.md: -------------------------------------------------------------------------------- 1 | # ActionCable Delivery Method 2 | 3 | Sends a notification to the browser via websockets (ActionCable channel by default). 4 | 5 | ```ruby 6 | deliver_by :action_cable do |config| 7 | config.channel = "Noticed::NotificationChannel" 8 | config.stream = ->{ recipient } 9 | config.message = ->{ params.merge( user_id: recipient.id) } 10 | end 11 | ``` 12 | 13 | ## Options 14 | 15 | * `message` 16 | 17 | Should return a Hash to be sent as the ActionCable message 18 | 19 | * `channel` 20 | 21 | Override the ActionCable channel used to send notifications. Defaults to `Noticed::NotificationChannel` 22 | 23 | * `stream` 24 | 25 | Should return the stream the message is broadcasted to. Defaults to `recipient` 26 | 27 | ## Authentication 28 | 29 | To send notifications to individual users, you'll want to use `stream_for current_user`. This requires `identified_by :current_user` in your ApplicationCable::Connection. For example, using Devise for authentication: 30 | 31 | ```ruby 32 | module ApplicationCable 33 | class Connection < ActionCable::Connection::Base 34 | identified_by :current_user 35 | 36 | def connect 37 | self.current_user = find_verified_user 38 | logger.add_tags "ActionCable", "User #{current_user.id}" 39 | end 40 | 41 | protected 42 | 43 | def find_verified_user 44 | if current_user = env['warden'].user 45 | current_user 46 | else 47 | reject_unauthorized_connection 48 | end 49 | end 50 | end 51 | end 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/delivery_methods/discord.md: -------------------------------------------------------------------------------- 1 | # Discord Bulk Delivery Method 2 | 3 | Send Discord messages to builk notify users in a channel. 4 | 5 | We recommend using [Discohook](https://discohook.org) to design your messages. 6 | 7 | ## Usage 8 | 9 | ```ruby 10 | class CommentNotification 11 | deliver_by :discord do |config| 12 | config.url = "https://discord.com..." 13 | config.json = -> { 14 | { 15 | # ... 16 | } 17 | } 18 | end 19 | end 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/delivery_methods/email.md: -------------------------------------------------------------------------------- 1 | ### Email Delivery Method 2 | 3 | Sends an email to each recipient. 4 | 5 | ```ruby 6 | deliver_by :email do |config| 7 | config.mailer = "UserMailer" 8 | config.method = :receipt 9 | config.params = ->{ params } 10 | config.args = ->{ [1, 2, 3] } 11 | 12 | # Enqueues a separate job for sending the email using deliver_later. 13 | # Deliveries already happen in jobs so this is typically unnecessary. 14 | # config.enqueue = false 15 | end 16 | ``` 17 | 18 | ##### Options 19 | 20 | - `mailer` - **Required** 21 | 22 | The mailer that should send the email 23 | 24 | - `method: :invoice_paid` - **Required** 25 | 26 | Used to customize the method on the mailer that is called 27 | 28 | - `params` - _Optional_ 29 | 30 | Use a custom method to define the params sent to the mailer. `recipient` will be merged into the params. 31 | 32 | - `args` - _Optional_ 33 | 34 | - `enqueue: false` - _Optional_ 35 | 36 | Use `deliver_later` to queue email delivery with ActiveJob. This is `false` by default as each delivery method is already a separate job. 37 | 38 | ##### ActionMailer::Preview 39 | 40 | Use `YourMailer.with({ recipient: user }).mailer_method_name` to set up a `ActionMailer::Preview`. And you can pass any number of params into the `Hash` but you will need the recipient key. 41 | 42 | ```ruby 43 | # test/mailers/previews/comment_mailer_preview.rb 44 | class CommentMailerPreview < ActionMailer::Preview 45 | def mailer_method_name 46 | CommentMailer.with({ recipient: User.first }).mailer_method_name 47 | end 48 | end 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/delivery_methods/fcm.md: -------------------------------------------------------------------------------- 1 | # Firebase Cloud Messaging Delivery Method 2 | 3 | Send Device Notifications using the Google Firebase Cloud Messaging service and the `googleauth` gem. FCM supports Android, iOS, and web clients. 4 | 5 | ```bash 6 | bundle add "googleauth" 7 | ``` 8 | 9 | ## Google Firebase Cloud Messaging Notification Service 10 | 11 | To generate your Firebase Cloud Messaging credentials, you'll need to create your project if you have not already. See https://console.firebase.google.com/u/1/ 12 | Once you have created your project, visit the project dashboard and click the settings cog in the top of the left sidebar menu, then click project settings. 13 | 14 | ![Firebase Console](../images/fcm-project-settings.png) 15 | 16 | In the project settings screen click on the Service accounts tab in the top navigation menu, then click the Generate new private key button. 17 | 18 | ![Service accounts](../images/fcm-credentials-json.png) 19 | 20 | This json file will contain the necessary credentials in order to send notifications via Google Firebase Cloud Messaging. 21 | See the below instructions on where to store this information within your application. 22 | 23 | ## Usage 24 | 25 | ```ruby 26 | class CommentNotification 27 | deliver_by :fcm do |config| 28 | config.credentials = Rails.root.join("config/certs/fcm.json") 29 | config.device_tokens = -> { recipient.notification_tokens.where(platform: "fcm").pluck(:token) } 30 | config.json = ->(device_token) { 31 | { 32 | message: { 33 | token: device_token, 34 | notification: { 35 | title: "Test Title", 36 | body: "Test body" 37 | } 38 | } 39 | } 40 | } 41 | config.if = -> { recipient.android_notifications? } 42 | end 43 | end 44 | ``` 45 | 46 | ## Options 47 | 48 | ### `json` 49 | Customize the Firebase Cloud Messaging notification object. This can be a Lambda or Symbol of a method name on the notifier. 50 | 51 | The callable object will be given the device token as an argument. 52 | 53 | There are lots of options of how to structure a FCM notification message. See https://firebase.google.com/docs/cloud-messaging/concept-options for more details. 54 | 55 | ### `credentials` 56 | The location of your Firebase Cloud Messaging credentials. 57 | 58 | #### When a String Object 59 | 60 | Internally, this string is passed to `Rails.root.join()` as an argument so there is no need to do this beforehand. 61 | 62 | ```ruby 63 | deliver_by :fcm do |config| 64 | config.credentials = "config/credentials/fcm.json" 65 | end 66 | ``` 67 | 68 | #### When a Pathname object 69 | 70 | The Pathname object can point to any location where you are storing your credentials. 71 | 72 | ```ruby 73 | deliver_by :fcm do |config| 74 | config.credentials = Rails.root.join("config/credentials/fcm.json") 75 | end 76 | ``` 77 | 78 | #### When a Hash object 79 | 80 | A Hash which contains your credentials 81 | 82 | ```ruby 83 | deliver_by :fcm do |config| 84 | config.credentials = credentials_hash 85 | end 86 | 87 | credentials_hash = { 88 | "type": "service_account", 89 | "project_id": "test-project-1234", 90 | "private_key_id": ..., 91 | etc..... 92 | } 93 | ``` 94 | 95 | #### When a Symbol 96 | 97 | Points to a method which can return a Hash of your credentials, Pathname, or String to your credentials like the examples above. 98 | 99 | We pass the notification object as an argument to the method. If you don't need to use it you can use the splat operator `(*)` to ignore it. 100 | 101 | ```ruby 102 | deliver_by :fcm do |config| 103 | config.credentials = :fcm_credentials 104 | config.json = :format_notification 105 | end 106 | 107 | def fcm_credentials(*) 108 | Rails.root.join("config/certs/fcm.json") 109 | end 110 | ``` 111 | 112 | #### Otherwise 113 | 114 | If the credentials option is left out, it will look for your credentials in: `Rails.application.credentials.fcm` 115 | 116 | ## Gathering Notification Tokens 117 | 118 | A recipient can have multiple tokens (i.e. multiple Android devices), so make sure to return them all. 119 | 120 | Here, the recipient `has_many :notification_tokens` with columns `platform` and `token`. 121 | 122 | ```ruby 123 | def fcm_device_tokens(recipient) 124 | recipient.notification_tokens.where(platform: "fcm").pluck(:token) 125 | end 126 | ``` 127 | 128 | ## Handling Failures 129 | 130 | Firebase Cloud Messaging Notifications may fail delivery if the user has removed the app from their device. 131 | 132 | ```ruby 133 | class CommentNotification 134 | deliver_by :fcm do |config| 135 | config.invalid_token = ->(device_token) { NotificationToken.find_by(token: device_token).destroy } 136 | end 137 | end 138 | ``` 139 | -------------------------------------------------------------------------------- /docs/delivery_methods/ios.md: -------------------------------------------------------------------------------- 1 | # iOS Notification Delivery Method 2 | 3 | Send Apple Push Notifications with HTTP2 using the `apnotic` gem. The benefit of HTTP2 is that we can receive feedback for invalid device tokens without running a separate feedback service like RPush does. 4 | 5 | ```bash 6 | bundle add "apnotic" 7 | ``` 8 | 9 | ## Apple Push Notification Service (APNS) Authentication 10 | 11 | Token-based authentication is used for APNS. 12 | * A single key can be used for every app in your developer account. 13 | * Token authentication never expires, unlike certificate authentication which must be renewed annually. 14 | 15 | Follow these docs for setting up Token-based authentication. 16 | https://github.com/ostinelli/apnotic#token-based-authentication 17 | https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns 18 | 19 | ## Usage 20 | 21 | ```ruby 22 | class CommentNotifier < ApplicationNotifier 23 | deliver_by :ios do |config| 24 | config.device_tokens = -> { recipient.notification_tokens.where(platform: :iOS).pluck(:token) } 25 | config.format = ->(apn) { 26 | apn.alert = "Hello world" 27 | apn.custom_payload = {url: root_url(host: "example.org")} 28 | } 29 | config.bundle_identifier = Rails.application.credentials.dig(:ios, :bundle_id) 30 | config.key_id = Rails.application.credentials.dig(:ios, :key_id) 31 | config.team_id = Rails.application.credentials.dig(:ios, :team_id) 32 | config.apns_key = Rails.application.credentials.dig(:ios, :apns_key) 33 | config.error_handler = ->(exception) { ... } 34 | end 35 | end 36 | ``` 37 | 38 | ## Options 39 | 40 | * `format` 41 | 42 | Customize the Apnotic notification object 43 | 44 | See https://github.com/ostinelli/apnotic#apnoticnotification 45 | 46 | * `bundle_identifier` 47 | 48 | The APN bundle identifier 49 | 50 | * `apns_key` 51 | 52 | The contents of your p8 apns key file. 53 | 54 | * `key_id` 55 | 56 | Your APN Key ID 57 | 58 | * `team_id` 59 | 60 | Your APN Team ID 61 | 62 | * `pool_size: 5` - *Optional* 63 | 64 | The connection pool size for Apnotic 65 | 66 | * `development` - *Optional* 67 | 68 | Set this to `true` to use the APNS sandbox environment for sending notifications. This is required when running the app to your device via Xcode. Running the app via TestFlight or the App Store should not use development. 69 | 70 | * `error_handler` - *Optional* 71 | A lambda to allow your app to handle Apnotic errors. 72 | 73 | ## Gathering Notification Tokens 74 | 75 | A recipient can have multiple tokens (i.e. multiple iOS devices), so make sure to return them all. 76 | 77 | Here, the recipient `has_many :notification_tokens` with columns `platform` and `token`. 78 | 79 | ```ruby 80 | deliver_by :ios do |config| 81 | config.device_tokens = -> { recipient.notification_tokens.where(platform: :iOS).pluck(:token) } 82 | end 83 | ``` 84 | 85 | ## Handling Failures 86 | 87 | Apple Push Notifications may fail delivery if the user has removed the app from their device. Noticed allows you 88 | 89 | ```ruby 90 | class CommentNotifier < ApplicationNotifier 91 | deliver_by :ios do |config| 92 | config.invalid_token = ->(token) { NotificationToken.where(token: token).destroy_all } 93 | end 94 | end 95 | ``` 96 | 97 | ## Updating the iOS app badge 98 | 99 | If you're managing the iOS app badge, you can pass it along in the format 100 | 101 | ```ruby 102 | class CommentNotifier < ApplicationNotifier 103 | deliver_by :ios do |config| 104 | config.format = ->(apn) { 105 | apn.alert = "Hello world" 106 | apn.custom_payload = {url: root_url(host: "example.org")} 107 | apn.badge = recipient.notifications.unread.count 108 | } 109 | end 110 | end 111 | ``` 112 | 113 | Another common action is to update the badge after a user reads a notification. 114 | 115 | This is a great use of the Noticed::Ephemeral class. Since it's all in-memory, it will perform the job and not touch the database. 116 | 117 | ```ruby 118 | class NativeBadgeNotifier < Noticed::Ephemeral 119 | deliver_by :ios do |config| 120 | config.format = ->(apn) { 121 | # Setting the alert text to nil will deliver the notification in 122 | # the background. This is used to update the app badge on the iOS home screen 123 | apn.alert = nil 124 | apn.custom_payload = {} 125 | apn.badge = recipient.notifications.unread.count 126 | } 127 | end 128 | end 129 | ``` 130 | 131 | Then you can simply deliver this notifier to update the badge when you mark the notification as read 132 | 133 | ```ruby 134 | notification.mark_as_read! 135 | NativeBadgeNotifier.with(record: notification).deliver(notification.recipient) 136 | ``` 137 | 138 | ## Delivering to Sandboxes and real devices 139 | 140 | If you wish to send notifications to both sandboxed and real devices from the same application, you can configure two iOS delivery methods 141 | A user has_many tokens that can be generated from both development (sandboxed devices), or production (not sandboxed devices) and is unrelated to the rails environment or endpoint being used. I 142 | 143 | ```ruby 144 | deliver_by :ios do |config| 145 | config.device_tokens = -> { recipient.notification_tokens.where(environment: :production, platform: :iOS).pluck(:token) } 146 | end 147 | 148 | deliver_by :ios_development, class: "Noticed::DeliveryMethods::Ios" do |config| 149 | config.development = true 150 | config.device_tokens = ->{ recipient.notification_tokens.where(environment: :development, platform: :iOS).pluck(:token) } 151 | end 152 | ``` 153 | -------------------------------------------------------------------------------- /docs/delivery_methods/microsoft_teams.md: -------------------------------------------------------------------------------- 1 | ### Microsoft Teams Delivery Method 2 | 3 | Sends a Teams notification via webhook. 4 | 5 | `deliver_by :microsoft_teams` 6 | 7 | #### Options 8 | 9 | * `format: :format_for_teams` - *Optional* 10 | 11 | Use a custom method to define the payload sent to Microsoft Teams. Method should return a Hash. 12 | Documentation for posting via Webhooks available at: https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook 13 | 14 | ```ruby 15 | { 16 | title: "This is the title for the card", 17 | text: "This is the body text for the card", 18 | sections: [{activityTitle: "Section Title", activityText: "Section Text"}], 19 | "potentialAction": [{ 20 | "@type": "OpenUri", 21 | name: "Button Text", 22 | targets: [{ 23 | os: "default", 24 | uri: "https://example.com/foo/action" 25 | }] 26 | }] 27 | 28 | } 29 | ``` 30 | 31 | * `url: :url_for_teams_channel`: - *Optional* 32 | 33 | Use a custom method to retrieve the MS Teams Webhook URL. Method should return a string. 34 | 35 | Defaults to `Rails.application.credentials.microsoft_teams[:notification_url]` 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/delivery_methods/slack.md: -------------------------------------------------------------------------------- 1 | # Slack Delivery Method 2 | 3 | Send a Slack message to notify users in a channel. 4 | 5 | ## Usage 6 | 7 | ```ruby 8 | class CommentNotification 9 | deliver_by :slack do |config| 10 | config.url = "https://slack.com..." 11 | config.json = -> { 12 | { 13 | # ... 14 | } 15 | } 16 | 17 | # Slack's chat.postMessage endpoint returns a 200 with {ok: true/false}. Disable this check by setting to false 18 | # config.raise_if_not_ok = true 19 | end 20 | end 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/delivery_methods/test.md: -------------------------------------------------------------------------------- 1 | # Test Delivery Method 2 | 3 | Saves deliveries for testing. 4 | 5 | ## Usage 6 | 7 | ```ruby 8 | class CommentNotification 9 | deliver_by :test 10 | end 11 | ``` 12 | 13 | ```ruby 14 | Noticed::DeliveryMethods::Test.delivered #=> [] 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/delivery_methods/twilio_messaging.md: -------------------------------------------------------------------------------- 1 | # Twilio Messaging Delivery Method 2 | 3 | Sends an SMS or Whatsapp message via Twilio Messaging. 4 | 5 | ```ruby 6 | deliver_by :twilio_messaging do |config| 7 | config.json = ->{ 8 | { 9 | From: phone_number, 10 | To: recipient.phone_number, 11 | Body: params.fetch(:message) 12 | } 13 | } 14 | 15 | config.credentials = { 16 | phone_number: Rails.application.credentials.dig(:twilio, :phone_number), 17 | account_sid: Rails.application.credentials.dig(:twilio, :account_sid), 18 | auth_token: Rails.application.credentials.dig(:twilio, :auth_token) 19 | } 20 | # config.credentials = Rails.application.credentials.twilio 21 | # config.phone = "+1234567890" 22 | # config.url = "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}/Messages.json" 23 | end 24 | ``` 25 | 26 | ## Content Templates 27 | 28 | ```ruby 29 | deliver_by :twilio_messaging do |config| 30 | config.json = -> { 31 | { 32 | From: "+1234567890", 33 | To: recipient.phone_number, 34 | ContentSid: "value", # Template SID 35 | ContentVariables: {1: recipient.first_name} 36 | } 37 | } 38 | end 39 | ``` 40 | 41 | ## Error Handling 42 | 43 | Twilio provides a full list of error codes that can be handled as needed. See https://www.twilio.com/docs/api/errors 44 | 45 | ```ruby 46 | deliver_by :twilio_messaging do |config| 47 | config.error_handler = lambda do |twilio_error_response| 48 | error_hash = JSON.parse(twilio_error_response.body) 49 | case error_hash["code"] 50 | when 21211 51 | # The 'To' number is not a valid phone number. 52 | # Write your error handling code 53 | else 54 | raise "Unhandled Twilio error: #{error_hash}" 55 | end 56 | end 57 | end 58 | ``` 59 | 60 | ## Options 61 | 62 | * `json` - *Optional* 63 | 64 | Use a custom method to define the payload sent to Twilio. Method should return a Hash. 65 | 66 | Defaults to: 67 | 68 | ```ruby 69 | { 70 | Body: params[:message], # From notification.params 71 | From: Rails.application.credentials.twilio[:phone_number], 72 | To: recipient.phone_number 73 | } 74 | ``` 75 | 76 | * `credentials` - *Optional* 77 | 78 | Retrieve the credentials for Twilio. Should return a Hash with `:account_sid`, `:auth_token` and `:phone_number` keys. 79 | 80 | Defaults to `Rails.application.credentials.twilio[:account_sid]` and `Rails.application.credentials.twilio[:auth_token]` 81 | 82 | * `url` - *Optional* 83 | 84 | Retrieve the Twilio URL. Should return the Twilio API url as a string. 85 | 86 | Defaults to `"https://api.twilio.com/2010-04-01/Accounts/#{twilio_credentials(recipient)[:account_sid]}/Messages.json"` 87 | -------------------------------------------------------------------------------- /docs/delivery_methods/vonage.md: -------------------------------------------------------------------------------- 1 | ### Vonage SMS 2 | 3 | Sends an SMS notification via Vonage / Nexmo. 4 | 5 | `deliver_by :vonage_sms` 6 | 7 | ##### Options 8 | 9 | * `credentials: :get_credentials` - *Optional* 10 | 11 | Use a custom method for retrieving credentials. Method should return a Hash with `:api_key` and `:api_secret` keys. 12 | 13 | Defaults to `Rails.application.credentials.vonage[:api_key]` and `Rails.application.credentials.vonage[:api_secret]` 14 | 15 | * `deliver_by :vonage, format: :format_for_vonage` - *Optional* 16 | 17 | Use a custom method to generate the params sent to Vonage. Method should return a Hash. Defaults to: 18 | 19 | ```ruby 20 | { 21 | api_key: vonage_credentials[:api_key], 22 | api_secret: vonage_credentials[:api_secret], 23 | from: notification.params[:from], 24 | text: notification.params[:body], 25 | to: notification.params[:to], 26 | type: "unicode" 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/delivery_methods/vonage_sms.md: -------------------------------------------------------------------------------- 1 | ### Vonage SMS 2 | 3 | Sends an SMS notification via Vonage / Nexmo. 4 | 5 | `deliver_by :vonage` 6 | 7 | ##### Options 8 | 9 | * `credentials: :get_credentials` - *Optional* 10 | 11 | Use a custom method for retrieving credentials. Method should return a Hash with `:api_key` and `:api_secret` keys. 12 | 13 | Defaults to `Rails.application.credentials.vonage[:api_key]` and `Rails.application.credentials.vonage[:api_secret]` 14 | 15 | * `deliver_by :vonage, format: :format_for_vonage` - *Optional* 16 | 17 | Use a custom method to generate the params sent to Vonage. Method should return a Hash. Defaults to: 18 | 19 | ```ruby 20 | { 21 | api_key: vonage_credentials[:api_key], 22 | api_secret: vonage_credentials[:api_secret], 23 | from: notification.params[:from], 24 | text: notification.params[:body], 25 | to: notification.params[:to], 26 | type: "unicode" 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/extending-noticed.md: -------------------------------------------------------------------------------- 1 | # Extending Noticed 2 | 3 | Noticed includes lazy load hooks that can be used to extend it's models and functionality. 4 | 5 | ### Example: Multitenancy 6 | 7 | This example adds multitenancy support to Noticed models so they have an `account:belongs_to` association. 8 | 9 | ```ruby 10 | ActiveSupport.on_load :noticed_event do 11 | belongs_to :account 12 | 13 | # Set account association from params 14 | def self.with(params) 15 | account = params.delete(:account) || Current.account 16 | record = params.delete(:record) 17 | 18 | # Instantiate Noticed::Event with account:belongs_to 19 | new(account: account, params: params, record: record) 20 | end 21 | 22 | def recipient_attributes_for(recipient) 23 | super.merge(account_id: account&.id || recipient.personal_account&.id) 24 | end 25 | end 26 | 27 | ActiveSupport.on_load :noticed_notification do 28 | belongs_to :account 29 | delegate :message, to: :event 30 | end 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/images/fcm-credentials-json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/docs/images/fcm-credentials-json.png -------------------------------------------------------------------------------- /docs/images/fcm-project-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/docs/images/fcm-project-settings.png -------------------------------------------------------------------------------- /gemfiles/rails_6_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 1.7" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "rails", "~> 6.1.0" 13 | gem "activerecord-trilogy-adapter" 14 | gem "bigdecimal" 15 | gem "drb" 16 | gem "mutex_m" 17 | gem "concurrent-ruby", "< 1.3.5" 18 | 19 | gemspec path: "../" 20 | -------------------------------------------------------------------------------- /gemfiles/rails_7_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 1.7" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "rails", "~> 7.0.0" 13 | gem "activerecord-trilogy-adapter" 14 | gem "bigdecimal" 15 | gem "drb" 16 | gem "mutex_m" 17 | gem "concurrent-ruby", "< 1.3.5" 18 | 19 | gemspec path: "../" 20 | -------------------------------------------------------------------------------- /gemfiles/rails_7_0.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | noticed (2.7.0) 5 | rails (>= 6.1.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (7.0.8.7) 11 | actionpack (= 7.0.8.7) 12 | activesupport (= 7.0.8.7) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | actionmailbox (7.0.8.7) 16 | actionpack (= 7.0.8.7) 17 | activejob (= 7.0.8.7) 18 | activerecord (= 7.0.8.7) 19 | activestorage (= 7.0.8.7) 20 | activesupport (= 7.0.8.7) 21 | mail (>= 2.7.1) 22 | net-imap 23 | net-pop 24 | net-smtp 25 | actionmailer (7.0.8.7) 26 | actionpack (= 7.0.8.7) 27 | actionview (= 7.0.8.7) 28 | activejob (= 7.0.8.7) 29 | activesupport (= 7.0.8.7) 30 | mail (~> 2.5, >= 2.5.4) 31 | net-imap 32 | net-pop 33 | net-smtp 34 | rails-dom-testing (~> 2.0) 35 | actionpack (7.0.8.7) 36 | actionview (= 7.0.8.7) 37 | activesupport (= 7.0.8.7) 38 | rack (~> 2.0, >= 2.2.4) 39 | rack-test (>= 0.6.3) 40 | rails-dom-testing (~> 2.0) 41 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 42 | actiontext (7.0.8.7) 43 | actionpack (= 7.0.8.7) 44 | activerecord (= 7.0.8.7) 45 | activestorage (= 7.0.8.7) 46 | activesupport (= 7.0.8.7) 47 | globalid (>= 0.6.0) 48 | nokogiri (>= 1.8.5) 49 | actionview (7.0.8.7) 50 | activesupport (= 7.0.8.7) 51 | builder (~> 3.1) 52 | erubi (~> 1.4) 53 | rails-dom-testing (~> 2.0) 54 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 55 | activejob (7.0.8.7) 56 | activesupport (= 7.0.8.7) 57 | globalid (>= 0.3.6) 58 | activemodel (7.0.8.7) 59 | activesupport (= 7.0.8.7) 60 | activerecord (7.0.8.7) 61 | activemodel (= 7.0.8.7) 62 | activesupport (= 7.0.8.7) 63 | activerecord-trilogy-adapter (3.1.2) 64 | activerecord (>= 6.0.a, < 7.1.a) 65 | trilogy (>= 2.4.0) 66 | activestorage (7.0.8.7) 67 | actionpack (= 7.0.8.7) 68 | activejob (= 7.0.8.7) 69 | activerecord (= 7.0.8.7) 70 | activesupport (= 7.0.8.7) 71 | marcel (~> 1.0) 72 | mini_mime (>= 1.1.0) 73 | activesupport (7.0.8.7) 74 | concurrent-ruby (~> 1.0, >= 1.0.2) 75 | i18n (>= 1.6, < 2) 76 | minitest (>= 5.1) 77 | tzinfo (~> 2.0) 78 | addressable (2.8.7) 79 | public_suffix (>= 2.0.2, < 7.0) 80 | apnotic (1.7.2) 81 | connection_pool (~> 2) 82 | net-http2 (>= 0.18.3, < 2) 83 | appraisal (2.5.0) 84 | bundler 85 | rake 86 | thor (>= 0.14.0) 87 | ast (2.4.3) 88 | base64 (0.2.0) 89 | bigdecimal (3.1.9) 90 | builder (3.3.0) 91 | concurrent-ruby (1.3.4) 92 | connection_pool (2.5.3) 93 | crack (1.0.0) 94 | bigdecimal 95 | rexml 96 | crass (1.0.6) 97 | date (3.4.1) 98 | drb (2.2.1) 99 | erubi (1.13.1) 100 | faraday (2.13.1) 101 | faraday-net_http (>= 2.0, < 3.5) 102 | json 103 | logger 104 | faraday-net_http (3.4.0) 105 | net-http (>= 0.5.0) 106 | globalid (1.2.1) 107 | activesupport (>= 6.1) 108 | google-cloud-env (2.3.0) 109 | base64 (~> 0.2) 110 | faraday (>= 1.0, < 3.a) 111 | google-logging-utils (0.2.0) 112 | googleauth (1.14.0) 113 | faraday (>= 1.0, < 3.a) 114 | google-cloud-env (~> 2.2) 115 | google-logging-utils (~> 0.1) 116 | jwt (>= 1.4, < 3.0) 117 | multi_json (~> 1.11) 118 | os (>= 0.9, < 2.0) 119 | signet (>= 0.16, < 2.a) 120 | hashdiff (1.1.2) 121 | http-2 (1.1.1) 122 | i18n (1.14.7) 123 | concurrent-ruby (~> 1.0) 124 | json (2.12.0) 125 | jwt (2.10.1) 126 | base64 127 | language_server-protocol (3.17.0.5) 128 | lint_roller (1.1.0) 129 | logger (1.7.0) 130 | loofah (2.24.1) 131 | crass (~> 1.0.2) 132 | nokogiri (>= 1.12.0) 133 | mail (2.8.1) 134 | mini_mime (>= 0.1.1) 135 | net-imap 136 | net-pop 137 | net-smtp 138 | marcel (1.0.4) 139 | method_source (1.1.0) 140 | mini_mime (1.1.5) 141 | mini_portile2 (2.8.9) 142 | minitest (5.25.5) 143 | multi_json (1.15.0) 144 | mutex_m (0.3.0) 145 | net-http (0.6.0) 146 | uri 147 | net-http2 (0.19.0) 148 | http-2 (>= 1.0) 149 | net-imap (0.5.8) 150 | date 151 | net-protocol 152 | net-pop (0.1.2) 153 | net-protocol 154 | net-protocol (0.2.2) 155 | timeout 156 | net-smtp (0.5.1) 157 | net-protocol 158 | nio4r (2.7.4) 159 | nokogiri (1.18.8) 160 | mini_portile2 (~> 2.8.2) 161 | racc (~> 1.4) 162 | nokogiri (1.18.8-aarch64-linux-gnu) 163 | racc (~> 1.4) 164 | nokogiri (1.18.8-aarch64-linux-musl) 165 | racc (~> 1.4) 166 | nokogiri (1.18.8-arm-linux-gnu) 167 | racc (~> 1.4) 168 | nokogiri (1.18.8-arm-linux-musl) 169 | racc (~> 1.4) 170 | nokogiri (1.18.8-arm64-darwin) 171 | racc (~> 1.4) 172 | nokogiri (1.18.8-x86_64-darwin) 173 | racc (~> 1.4) 174 | nokogiri (1.18.8-x86_64-linux-gnu) 175 | racc (~> 1.4) 176 | nokogiri (1.18.8-x86_64-linux-musl) 177 | racc (~> 1.4) 178 | os (1.1.4) 179 | parallel (1.27.0) 180 | parser (3.3.8.0) 181 | ast (~> 2.4.1) 182 | racc 183 | pg (1.5.9) 184 | prism (1.4.0) 185 | public_suffix (6.0.2) 186 | racc (1.8.1) 187 | rack (2.2.14) 188 | rack-test (2.2.0) 189 | rack (>= 1.3) 190 | rails (7.0.8.7) 191 | actioncable (= 7.0.8.7) 192 | actionmailbox (= 7.0.8.7) 193 | actionmailer (= 7.0.8.7) 194 | actionpack (= 7.0.8.7) 195 | actiontext (= 7.0.8.7) 196 | actionview (= 7.0.8.7) 197 | activejob (= 7.0.8.7) 198 | activemodel (= 7.0.8.7) 199 | activerecord (= 7.0.8.7) 200 | activestorage (= 7.0.8.7) 201 | activesupport (= 7.0.8.7) 202 | bundler (>= 1.15.0) 203 | railties (= 7.0.8.7) 204 | rails-dom-testing (2.2.0) 205 | activesupport (>= 5.0.0) 206 | minitest 207 | nokogiri (>= 1.6) 208 | rails-html-sanitizer (1.6.2) 209 | loofah (~> 2.21) 210 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 211 | railties (7.0.8.7) 212 | actionpack (= 7.0.8.7) 213 | activesupport (= 7.0.8.7) 214 | method_source 215 | rake (>= 12.2) 216 | thor (~> 1.0) 217 | zeitwerk (~> 2.5) 218 | rainbow (3.1.1) 219 | rake (13.2.1) 220 | regexp_parser (2.10.0) 221 | rexml (3.4.1) 222 | rubocop (1.75.6) 223 | json (~> 2.3) 224 | language_server-protocol (~> 3.17.0.2) 225 | lint_roller (~> 1.1.0) 226 | parallel (~> 1.10) 227 | parser (>= 3.3.0.2) 228 | rainbow (>= 2.2.2, < 4.0) 229 | regexp_parser (>= 2.9.3, < 3.0) 230 | rubocop-ast (>= 1.44.0, < 2.0) 231 | ruby-progressbar (~> 1.7) 232 | unicode-display_width (>= 2.4.0, < 4.0) 233 | rubocop-ast (1.44.1) 234 | parser (>= 3.3.7.2) 235 | prism (~> 1.4) 236 | rubocop-performance (1.25.0) 237 | lint_roller (~> 1.1) 238 | rubocop (>= 1.75.0, < 2.0) 239 | rubocop-ast (>= 1.38.0, < 2.0) 240 | ruby-progressbar (1.13.0) 241 | signet (0.20.0) 242 | addressable (~> 2.8) 243 | faraday (>= 0.17.5, < 3.a) 244 | jwt (>= 1.5, < 3.0) 245 | multi_json (~> 1.10) 246 | sqlite3 (1.7.3) 247 | mini_portile2 (~> 2.8.0) 248 | sqlite3 (1.7.3-aarch64-linux) 249 | sqlite3 (1.7.3-arm-linux) 250 | sqlite3 (1.7.3-arm64-darwin) 251 | sqlite3 (1.7.3-x86-linux) 252 | sqlite3 (1.7.3-x86_64-darwin) 253 | sqlite3 (1.7.3-x86_64-linux) 254 | standard (1.50.0) 255 | language_server-protocol (~> 3.17.0.2) 256 | lint_roller (~> 1.0) 257 | rubocop (~> 1.75.5) 258 | standard-custom (~> 1.0.0) 259 | standard-performance (~> 1.8) 260 | standard-custom (1.0.2) 261 | lint_roller (~> 1.0) 262 | rubocop (~> 1.50) 263 | standard-performance (1.8.0) 264 | lint_roller (~> 1.1) 265 | rubocop-performance (~> 1.25.0) 266 | thor (1.3.2) 267 | timeout (0.4.3) 268 | trilogy (2.9.0) 269 | tzinfo (2.0.6) 270 | concurrent-ruby (~> 1.0) 271 | unicode-display_width (3.1.4) 272 | unicode-emoji (~> 4.0, >= 4.0.4) 273 | unicode-emoji (4.0.4) 274 | uri (1.0.3) 275 | webmock (3.25.1) 276 | addressable (>= 2.8.0) 277 | crack (>= 0.3.2) 278 | hashdiff (>= 0.4.0, < 2.0.0) 279 | websocket-driver (0.7.7) 280 | base64 281 | websocket-extensions (>= 0.1.0) 282 | websocket-extensions (0.1.5) 283 | zeitwerk (2.7.2) 284 | 285 | PLATFORMS 286 | aarch64-linux 287 | aarch64-linux-gnu 288 | aarch64-linux-musl 289 | arm-linux 290 | arm-linux-gnu 291 | arm-linux-musl 292 | arm64-darwin 293 | ruby 294 | x86-linux 295 | x86_64-darwin 296 | x86_64-linux 297 | x86_64-linux-gnu 298 | x86_64-linux-musl 299 | 300 | DEPENDENCIES 301 | activerecord-trilogy-adapter 302 | apnotic (~> 1.7) 303 | appraisal 304 | bigdecimal 305 | concurrent-ruby (< 1.3.5) 306 | drb 307 | googleauth (~> 1.1) 308 | mutex_m 309 | noticed! 310 | pg 311 | rails (~> 7.0.0) 312 | sqlite3 (~> 1.7) 313 | standard 314 | webmock 315 | 316 | BUNDLED WITH 317 | 2.6.8 318 | -------------------------------------------------------------------------------- /gemfiles/rails_7_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 1.7" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "rails", "~> 7.1.0" 13 | gem "trilogy" 14 | 15 | gemspec path: "../" 16 | -------------------------------------------------------------------------------- /gemfiles/rails_7_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 1.7" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "rails", "~> 7.2.0" 13 | gem "trilogy" 14 | 15 | gemspec path: "../" 16 | -------------------------------------------------------------------------------- /gemfiles/rails_8_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 2.0" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "rails", "~> 8.0.0" 13 | gem "trilogy" 14 | 15 | gemspec path: "../" 16 | -------------------------------------------------------------------------------- /gemfiles/rails_main.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "pg" 7 | gem "sqlite3", "~> 2.0" 8 | gem "standard" 9 | gem "webmock" 10 | gem "apnotic", "~> 1.7" 11 | gem "googleauth", "~> 1.1" 12 | gem "rails", branch: "main", git: "https://github.com/rails/rails.git" 13 | gem "trilogy" 14 | 15 | gemspec path: "../" 16 | -------------------------------------------------------------------------------- /lib/generators/noticed/delivery_method_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/named_base" 4 | 5 | module Noticed 6 | module Generators 7 | class DeliveryMethodGenerator < Rails::Generators::NamedBase 8 | include Rails::Generators::ResourceHelpers 9 | 10 | source_root File.expand_path("../templates", __FILE__) 11 | 12 | desc "Generates a class for a custom delivery method with the given NAME." 13 | 14 | def generate_notification 15 | template "application_delivery_method.rb", "app/notifiers/application_delivery_method.rb" 16 | template "delivery_method.rb", "app/notifiers/delivery_methods/#{singular_name}.rb" 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/generators/noticed/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Noticed 4 | module Generators 5 | class ModelGenerator < Rails::Generators::Base 6 | include Rails::Generators::ResourceHelpers 7 | 8 | source_root File.expand_path("../templates", __FILE__) 9 | 10 | def create_migrations 11 | rails_command "railties:install:migrations FROM=noticed", inline: true 12 | end 13 | 14 | def done 15 | readme "README" if behavior == :invoke 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/generators/noticed/notifier_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/named_base" 4 | 5 | module Noticed 6 | module Generators 7 | class NotifierGenerator < Rails::Generators::NamedBase 8 | include Rails::Generators::ResourceHelpers 9 | 10 | check_class_collision suffix: "Notifier" 11 | 12 | source_root File.expand_path("../templates", __FILE__) 13 | 14 | desc "Generates a notification with the given NAME." 15 | 16 | def generate_abstract_class 17 | return if File.exist?("app/notifiers/application_notifier.rb") 18 | template "application_notifier.rb", "app/notifiers/application_notifier.rb" 19 | end 20 | 21 | def generate_notification 22 | template "notifier.rb", "app/notifiers/#{file_path}_notifier.rb" 23 | end 24 | 25 | private 26 | 27 | def file_name # :doc: 28 | @_file_name ||= super.sub(/_notifier\z/i, "") 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/generators/noticed/templates/README: -------------------------------------------------------------------------------- 1 | 2 | 🚚 You're ready to start sending notifications! 3 | 4 | Next steps: 5 | 1. Run `rails db:migrate` 6 | 2. Add `has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification"` to your User model(s). 7 | 2. Add `has_many :notifications, as: :record, dependent: :destroy, class_name: "Noticed::Event"` to your model(s) that notifications reference. 8 | 3. Generate notifiers with "rails g noticed:notifier" 9 | -------------------------------------------------------------------------------- /lib/generators/noticed/templates/application_delivery_method.rb.tt: -------------------------------------------------------------------------------- 1 | class ApplicationDeliveryMethod < Noticed::DeliveryMethod 2 | end 3 | -------------------------------------------------------------------------------- /lib/generators/noticed/templates/application_notifier.rb.tt: -------------------------------------------------------------------------------- 1 | class ApplicationNotifier < Noticed::Event 2 | end 3 | -------------------------------------------------------------------------------- /lib/generators/noticed/templates/delivery_method.rb.tt: -------------------------------------------------------------------------------- 1 | class DeliveryMethods::<%= class_name %> < ApplicationDeliveryMethod 2 | # Specify the config options your delivery method requires in its config block 3 | required_options # :foo, :bar 4 | 5 | def deliver 6 | # Logic for sending the notification 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/generators/noticed/templates/notifier.rb.tt: -------------------------------------------------------------------------------- 1 | # To deliver this notification: 2 | # 3 | # <%= class_name %>Notifier.with(record: @post, message: "New post").deliver(User.all) 4 | 5 | class <%= class_name %>Notifier < ApplicationNotifier 6 | # Add your delivery methods 7 | # 8 | # deliver_by :email do |config| 9 | # config.mailer = "UserMailer" 10 | # config.method = "new_post" 11 | # end 12 | # 13 | # bulk_deliver_by :slack do |config| 14 | # config.url = -> { Rails.application.credentials.slack_webhook_url } 15 | # end 16 | # 17 | # deliver_by :custom do |config| 18 | # config.class = "MyDeliveryMethod" 19 | # end 20 | 21 | # Add required params 22 | # 23 | # required_param :message 24 | end 25 | -------------------------------------------------------------------------------- /lib/noticed.rb: -------------------------------------------------------------------------------- 1 | require "noticed/version" 2 | require "noticed/engine" 3 | 4 | module Noticed 5 | include ActiveSupport::Deprecation::DeprecatedConstantAccessor 6 | 7 | def self.deprecator # :nodoc: 8 | @deprecator ||= ActiveSupport::Deprecation.new 9 | end 10 | 11 | deprecate_constant :Base, "Noticed::Event", deprecator: deprecator 12 | 13 | autoload :ApiClient, "noticed/api_client" 14 | autoload :BulkDeliveryMethod, "noticed/bulk_delivery_method" 15 | autoload :Coder, "noticed/coder" 16 | autoload :DeliveryMethod, "noticed/delivery_method" 17 | autoload :HasNotifications, "noticed/has_notifications" 18 | autoload :NotificationChannel, "noticed/notification_channel" 19 | autoload :RequiredOptions, "noticed/required_options" 20 | autoload :Translation, "noticed/translation" 21 | 22 | module BulkDeliveryMethods 23 | autoload :Bluesky, "noticed/bulk_delivery_methods/bluesky" 24 | autoload :Discord, "noticed/bulk_delivery_methods/discord" 25 | autoload :Slack, "noticed/bulk_delivery_methods/slack" 26 | autoload :Test, "noticed/bulk_delivery_methods/test" 27 | autoload :Webhook, "noticed/bulk_delivery_methods/webhook" 28 | end 29 | 30 | module DeliveryMethods 31 | include ActiveSupport::Deprecation::DeprecatedConstantAccessor 32 | deprecate_constant :Base, "Noticed::DeliveryMethod", deprecator: Noticed.deprecator 33 | 34 | autoload :ActionCable, "noticed/delivery_methods/action_cable" 35 | autoload :Email, "noticed/delivery_methods/email" 36 | autoload :Fcm, "noticed/delivery_methods/fcm" 37 | autoload :Ios, "noticed/delivery_methods/ios" 38 | autoload :MicrosoftTeams, "noticed/delivery_methods/microsoft_teams" 39 | autoload :Slack, "noticed/delivery_methods/slack" 40 | autoload :Test, "noticed/delivery_methods/test" 41 | autoload :TwilioMessaging, "noticed/delivery_methods/twilio_messaging" 42 | autoload :VonageSms, "noticed/delivery_methods/vonage_sms" 43 | autoload :Webhook, "noticed/delivery_methods/webhook" 44 | end 45 | 46 | mattr_accessor :parent_class 47 | @@parent_class = "Noticed::ApplicationJob" 48 | 49 | class ValidationError < StandardError 50 | end 51 | 52 | class ResponseUnsuccessful < StandardError 53 | attr_reader :response 54 | 55 | def initialize(response, url, args) 56 | @response = response 57 | @url = url 58 | @args = args 59 | 60 | super("POST request to #{url} returned #{response.code} response:\n#{response.body.inspect}") 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/noticed/api_client.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | 3 | module Noticed 4 | module ApiClient 5 | extend ActiveSupport::Concern 6 | 7 | # Helper method for making POST requests from delivery methods 8 | # 9 | # Usage: 10 | # post_request("http://example.com", basic_auth: {user:, pass:}, headers: {}, json: {}, form: {}) 11 | # 12 | def post_request(url, args = {}) 13 | args.compact! 14 | 15 | uri = URI(url) 16 | http = Net::HTTP.new(uri.host, uri.port) 17 | http.use_ssl = true if uri.instance_of? URI::HTTPS 18 | 19 | headers = args.delete(:headers) || {} 20 | headers["Content-Type"] = "application/json" if args.has_key?(:json) 21 | 22 | request = Net::HTTP::Post.new(uri.request_uri, headers) 23 | 24 | if (basic_auth = args.delete(:basic_auth)) 25 | request.basic_auth basic_auth.fetch(:user), basic_auth.fetch(:pass) 26 | end 27 | 28 | if (json = args.delete(:json)) 29 | request.body = json.to_json 30 | elsif (form = args.delete(:form)) 31 | request.form_data = form 32 | end 33 | 34 | logger.debug("POST #{url}") 35 | logger.debug(request.body) 36 | response = http.request(request) 37 | logger.debug("Response: #{response.code}: #{response.body.inspect}") 38 | 39 | raise ResponseUnsuccessful.new(response, url, args) unless response.code.start_with?("20") 40 | 41 | response 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_method.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class BulkDeliveryMethod < Noticed.parent_class.constantize 3 | include ApiClient 4 | include RequiredOptions 5 | 6 | extend ActiveModel::Callbacks 7 | define_model_callbacks :deliver 8 | 9 | class_attribute :logger, default: Rails.logger 10 | 11 | attr_reader :config, :event 12 | 13 | def perform(delivery_method_name, event, recipient: nil, params: {}, overrides: {}) 14 | # Ephemeral notifications 15 | if event.is_a? String 16 | @event = event.constantize.new_with_params(recipient, params) 17 | @config = overrides 18 | else 19 | @event = event 20 | @config = event.bulk_delivery_methods.fetch(delivery_method_name).config.merge(overrides) 21 | end 22 | 23 | return false if config.has_key?(:if) && !evaluate_option(:if) 24 | return false if config.has_key?(:unless) && evaluate_option(:unless) 25 | 26 | run_callbacks :deliver do 27 | deliver 28 | end 29 | end 30 | 31 | def deliver 32 | raise NotImplementedError, "Bulk delivery methods must implement the `deliver` method" 33 | end 34 | 35 | def fetch_constant(name) 36 | option = config[name] 37 | option.is_a?(String) ? option.constantize : evaluate_option(option) 38 | end 39 | 40 | def evaluate_option(name) 41 | option = config[name] 42 | 43 | # Evaluate Proc within the context of the notifier 44 | if option&.respond_to?(:call) 45 | event.instance_exec(&option) 46 | 47 | # Call method if symbol and matching method 48 | elsif option.is_a?(Symbol) && event.respond_to?(option, true) 49 | event.send(option) 50 | 51 | # Return the value 52 | else 53 | option 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_methods/bluesky.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module BulkDeliveryMethods 3 | class Bluesky < BulkDeliveryMethod 4 | required_options :identifier, :password, :json 5 | 6 | # bulk_deliver_by :bluesky do |config| 7 | # config.identifier = ENV["BLUESKY_ID"] 8 | # config.password = ENV["BLUESKY_PASSWORD"] 9 | # config.json = {text: "...", createdAt: "..."} 10 | # end 11 | 12 | def deliver 13 | Rails.logger.debug(evaluate_option(:json)) 14 | post_request( 15 | "https://#{host}/xrpc/com.atproto.repo.createRecord", 16 | headers: {"Authorization" => "Bearer #{token}"}, 17 | json: { 18 | repo: identifier, 19 | collection: "app.bsky.feed.post", 20 | record: evaluate_option(:json) 21 | } 22 | ) 23 | end 24 | 25 | def token 26 | start_session.dig("accessJwt") 27 | end 28 | 29 | def start_session 30 | response = post_request( 31 | "https://#{host}/xrpc/com.atproto.server.createSession", 32 | json: { 33 | identifier: identifier, 34 | password: evaluate_option(:password) 35 | } 36 | ) 37 | JSON.parse(response.body) 38 | end 39 | 40 | def host 41 | @host ||= evaluate_option(:host) || "bsky.social" 42 | end 43 | 44 | def identifier 45 | @identifier ||= evaluate_option(:identifier) 46 | end 47 | end 48 | end 49 | end 50 | 51 | ActiveSupport.run_load_hooks :noticed_bulk_delivery_methods_bluesky, Noticed::BulkDeliveryMethods::Bluesky 52 | -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_methods/discord.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module BulkDeliveryMethods 3 | class Discord < BulkDeliveryMethod 4 | required_options :json, :url 5 | 6 | def deliver 7 | post_request evaluate_option(:url), headers: evaluate_option(:headers), json: evaluate_option(:json) 8 | end 9 | end 10 | end 11 | end 12 | 13 | ActiveSupport.run_load_hooks :noticed_bulk_delivery_methods_discord, Noticed::BulkDeliveryMethods::Discord 14 | -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_methods/slack.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module BulkDeliveryMethods 3 | class Slack < BulkDeliveryMethod 4 | DEFAULT_URL = "https://slack.com/api/chat.postMessage" 5 | 6 | required_options :json 7 | 8 | def deliver 9 | headers = evaluate_option(:headers) 10 | json = evaluate_option(:json) 11 | response = post_request url, headers: headers, json: json 12 | 13 | if raise_if_not_ok? && !success?(response) 14 | raise ResponseUnsuccessful.new(response, url, {headers: headers, json: json}) 15 | end 16 | 17 | response 18 | end 19 | 20 | def url 21 | evaluate_option(:url) || DEFAULT_URL 22 | end 23 | 24 | def raise_if_not_ok? 25 | value = evaluate_option(:raise_if_not_ok) 26 | value.nil? || value 27 | end 28 | 29 | def success?(response) 30 | if response.content_type == "application/json" 31 | JSON.parse(response.body).dig("ok") 32 | else 33 | # https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks 34 | response.is_a?(Net::HTTPSuccess) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | 41 | ActiveSupport.run_load_hooks :noticed_bulk_delivery_methods_slack, Noticed::BulkDeliveryMethods::Slack 42 | -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_methods/test.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module BulkDeliveryMethods 3 | class Test < BulkDeliveryMethod 4 | class_attribute :delivered, default: [] 5 | 6 | def deliver 7 | delivered << event 8 | end 9 | end 10 | end 11 | end 12 | 13 | ActiveSupport.run_load_hooks :noticed_bulk_delivery_methods_test, Noticed::BulkDeliveryMethods::Test 14 | -------------------------------------------------------------------------------- /lib/noticed/bulk_delivery_methods/webhook.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module BulkDeliveryMethods 3 | class Webhook < BulkDeliveryMethod 4 | required_options :url 5 | 6 | def deliver 7 | Rails.logger.debug(evaluate_option(:json)) 8 | post_request( 9 | evaluate_option(:url), 10 | basic_auth: evaluate_option(:basic_auth), 11 | headers: evaluate_option(:headers), 12 | json: evaluate_option(:json), 13 | form: evaluate_option(:form) 14 | ) 15 | end 16 | end 17 | end 18 | end 19 | 20 | ActiveSupport.run_load_hooks :noticed_bulk_delivery_methods_webhook, Noticed::BulkDeliveryMethods::Webhook 21 | -------------------------------------------------------------------------------- /lib/noticed/coder.rb: -------------------------------------------------------------------------------- 1 | require "active_job/arguments" 2 | 3 | module Noticed 4 | class Coder 5 | def self.load(data) 6 | return if data.nil? 7 | ActiveJob::Arguments.send(:deserialize_argument, data) 8 | rescue ActiveRecord::RecordNotFound => error 9 | {noticed_error: error.message, original_params: data} 10 | end 11 | 12 | def self.dump(data) 13 | return if data.nil? 14 | ActiveJob::Arguments.send(:serialize_argument, data) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/noticed/delivery_method.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class DeliveryMethod < Noticed.parent_class.constantize 3 | include ApiClient 4 | include RequiredOptions 5 | 6 | extend ActiveModel::Callbacks 7 | define_model_callbacks :deliver 8 | 9 | class_attribute :logger, default: Rails.logger 10 | 11 | attr_reader :config, :event, :notification 12 | delegate :recipient, to: :notification 13 | delegate :record, :params, to: :event 14 | 15 | def perform(delivery_method_name, notification, recipient: nil, params: {}, overrides: {}) 16 | # Ephemeral notifications 17 | if notification.is_a? String 18 | @notification = notification.constantize.new_with_params(recipient, params) 19 | @event = @notification.event 20 | else 21 | @notification = notification 22 | @event = notification.event 23 | end 24 | 25 | # Look up config from Notifier and merge overrides 26 | @config = event.delivery_methods.fetch(delivery_method_name).config.merge(overrides) 27 | 28 | return false if config.has_key?(:if) && !evaluate_option(:if) 29 | return false if config.has_key?(:unless) && evaluate_option(:unless) 30 | 31 | run_callbacks :deliver do 32 | deliver 33 | end 34 | end 35 | 36 | def deliver 37 | raise NotImplementedError, "Delivery methods must implement the `deliver` method" 38 | end 39 | 40 | def fetch_constant(name) 41 | option = evaluate_option(name) 42 | option.is_a?(String) ? option.constantize : option 43 | end 44 | 45 | def evaluate_option(name) 46 | option = config[name] 47 | 48 | # Evaluate Proc within the context of the Notification 49 | if option&.respond_to?(:call) 50 | notification.instance_exec(&option) 51 | 52 | # Call method if symbol and matching method on Notifier 53 | elsif option.is_a?(Symbol) && event.respond_to?(option, true) 54 | event.send(option, notification) 55 | 56 | # Return the value 57 | else 58 | option 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/action_cable.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class ActionCable < DeliveryMethod 4 | required_options :message 5 | 6 | def deliver 7 | channel.broadcast_to stream, evaluate_option(:message) 8 | end 9 | 10 | def channel 11 | fetch_constant(:channel) || Noticed::NotificationChannel 12 | end 13 | 14 | def stream 15 | evaluate_option(:stream) || recipient 16 | end 17 | end 18 | end 19 | end 20 | 21 | ActiveSupport.run_load_hooks :noticed_delivery_methods_action_cable, Noticed::DeliveryMethods::ActionCable 22 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/discord.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class Discord < BulkDeliveryMethod 4 | required_options :json, :url 5 | 6 | def deliver 7 | post_request evaluate_option(:url), headers: evaluate_option(:headers), json: evaluate_option(:json) 8 | end 9 | end 10 | end 11 | end 12 | 13 | ActiveSupport.run_load_hooks :noticed_delivery_methods_discord, Noticed::DeliveryMethods::Discord 14 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/email.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class Email < DeliveryMethod 4 | required_options :mailer, :method 5 | 6 | def deliver 7 | mailer = fetch_constant(:mailer) 8 | email = evaluate_option(:method) 9 | args = evaluate_option(:args) || [] 10 | mail = mailer.with(params).public_send(email, *args) 11 | (!!evaluate_option(:enqueue)) ? mail.deliver_later : mail.deliver_now 12 | end 13 | 14 | def params 15 | (evaluate_option(:params) || notification&.params || {}).merge( 16 | notification: notification, 17 | record: notification&.record, 18 | recipient: notification&.recipient 19 | ) 20 | end 21 | end 22 | end 23 | end 24 | 25 | ActiveSupport.run_load_hooks :noticed_delivery_methods_email, Noticed::DeliveryMethods::Email 26 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/fcm.rb: -------------------------------------------------------------------------------- 1 | require "googleauth" 2 | 3 | module Noticed 4 | module DeliveryMethods 5 | class Fcm < DeliveryMethod 6 | required_option :credentials, :device_tokens, :json 7 | 8 | def deliver 9 | evaluate_option(:device_tokens).each do |device_token| 10 | send_notification device_token 11 | end 12 | end 13 | 14 | def send_notification(device_token) 15 | post_request("https://fcm.googleapis.com/v1/projects/#{credentials[:project_id]}/messages:send", 16 | headers: {authorization: "Bearer #{access_token}"}, 17 | json: format_notification(device_token)) 18 | rescue Noticed::ResponseUnsuccessful => exception 19 | if bad_token?(exception.response) && config[:invalid_token] 20 | notification.instance_exec(device_token, &config[:invalid_token]) 21 | else 22 | raise 23 | end 24 | end 25 | 26 | def format_notification(device_token) 27 | method = config[:json] 28 | if method.is_a?(Symbol) && event.respond_to?(method, true) 29 | event.send(method, device_token) 30 | else 31 | notification.instance_exec(device_token, &method) 32 | end 33 | end 34 | 35 | def bad_token?(response) 36 | response.code == "404" || response.code == "400" 37 | end 38 | 39 | def credentials 40 | @credentials ||= begin 41 | value = evaluate_option(:credentials) 42 | case value 43 | when Hash 44 | value 45 | when Pathname 46 | load_json(value) 47 | when String 48 | load_json(Rails.root.join(value)) 49 | else 50 | raise ArgumentError, "FCM credentials must be a Hash, String, Pathname, or Symbol" 51 | end 52 | end 53 | end 54 | 55 | def load_json(path) 56 | JSON.parse(File.read(path), symbolize_names: true) 57 | end 58 | 59 | def access_token 60 | @authorizer ||= (evaluate_option(:authorizer) || Google::Auth::ServiceAccountCredentials).make_creds( 61 | json_key_io: StringIO.new(credentials.to_json), 62 | scope: "https://www.googleapis.com/auth/firebase.messaging" 63 | ) 64 | @authorizer.fetch_access_token!["access_token"] 65 | end 66 | end 67 | end 68 | end 69 | 70 | ActiveSupport.run_load_hooks :noticed_delivery_methods_fcm, Noticed::DeliveryMethods::Fcm 71 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/ios.rb: -------------------------------------------------------------------------------- 1 | require "apnotic" 2 | 3 | module Noticed 4 | module DeliveryMethods 5 | class Ios < DeliveryMethod 6 | cattr_accessor :development_connection_pool, :production_connection_pool 7 | 8 | required_options :bundle_identifier, :key_id, :team_id, :apns_key, :device_tokens 9 | 10 | def deliver 11 | evaluate_option(:device_tokens).each do |device_token| 12 | apn = Apnotic::Notification.new(device_token) 13 | format_notification(apn) 14 | 15 | connection_pool = (!!evaluate_option(:development)) ? development_pool : production_pool 16 | connection_pool.with do |connection| 17 | response = connection.push(apn) 18 | raise "Timeout sending iOS push notification" unless response 19 | connection.close 20 | 21 | if bad_token?(response) && config[:invalid_token] 22 | # Allow notification to cleanup invalid iOS device tokens 23 | notification.instance_exec(device_token, &config[:invalid_token]) 24 | elsif !response.ok? 25 | raise "Request failed #{response.body}" 26 | end 27 | end 28 | end 29 | end 30 | 31 | private 32 | 33 | def format_notification(apn) 34 | apn.topic = evaluate_option(:bundle_identifier) 35 | 36 | method = config[:format] 37 | # Call method on Notifier if defined 38 | if method&.is_a?(Symbol) && event.respond_to?(method, true) 39 | event.send(method, notification, apn) 40 | # If Proc, evaluate it on the Notification 41 | elsif method&.respond_to?(:call) 42 | notification.instance_exec(apn, &method) 43 | elsif notification.params.try(:has_key?, :message) 44 | apn.alert = notification.params[:message] 45 | else 46 | raise ArgumentError, "No message for iOS delivery. Either include message in params or add the 'format' option in 'deliver_by :ios'." 47 | end 48 | end 49 | 50 | def bad_token?(response) 51 | response.status == "410" || (response.status == "400" && response.body["reason"] == "BadDeviceToken") 52 | end 53 | 54 | def development_pool 55 | self.class.development_connection_pool ||= new_connection_pool(development: true) 56 | end 57 | 58 | def production_pool 59 | self.class.production_connection_pool ||= new_connection_pool(development: false) 60 | end 61 | 62 | def new_connection_pool(development:) 63 | handler = proc do |connection| 64 | connection.on(:error) do |exception| 65 | Rails.logger.info "Apnotic exception raised: #{exception}" 66 | if config[:error_handler].respond_to?(:call) 67 | notification.instance_exec(exception, &config[:error_handler]) 68 | end 69 | end 70 | end 71 | 72 | if development 73 | Apnotic::ConnectionPool.development(connection_pool_options, pool_options, &handler) 74 | else 75 | Apnotic::ConnectionPool.new(connection_pool_options, pool_options, &handler) 76 | end 77 | end 78 | 79 | def connection_pool_options 80 | { 81 | auth_method: :token, 82 | cert_path: StringIO.new(evaluate_option(:apns_key)), 83 | key_id: evaluate_option(:key_id), 84 | team_id: evaluate_option(:team_id) 85 | } 86 | end 87 | 88 | def pool_options 89 | {size: evaluate_option(:pool_size) || 5} 90 | end 91 | end 92 | end 93 | end 94 | 95 | ActiveSupport.run_load_hooks :noticed_delivery_methods_ios, Noticed::DeliveryMethods::Ios 96 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/microsoft_teams.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class MicrosoftTeams < DeliveryMethod 4 | required_options :json 5 | 6 | def deliver 7 | post_request url, headers: evaluate_option(:headers), json: evaluate_option(:json) 8 | end 9 | 10 | def url 11 | evaluate_option(:url) || Rails.application.credentials.dig(:microsoft_teams, :notification_url) 12 | end 13 | end 14 | end 15 | end 16 | 17 | ActiveSupport.run_load_hooks :noticed_delivery_methods_microsoft_teams, Noticed::DeliveryMethods::MicrosoftTeams 18 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/slack.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class Slack < DeliveryMethod 4 | DEFAULT_URL = "https://slack.com/api/chat.postMessage" 5 | 6 | required_options :json 7 | 8 | def deliver 9 | headers = evaluate_option(:headers) 10 | json = evaluate_option(:json) 11 | response = post_request url, headers: headers, json: json 12 | 13 | if raise_if_not_ok? && !success?(response) 14 | raise ResponseUnsuccessful.new(response, url, {headers: headers, json: json}) 15 | end 16 | 17 | response 18 | end 19 | 20 | def url 21 | evaluate_option(:url) || DEFAULT_URL 22 | end 23 | 24 | def raise_if_not_ok? 25 | value = evaluate_option(:raise_if_not_ok) 26 | value.nil? || value 27 | end 28 | 29 | def success?(response) 30 | if response.content_type == "application/json" 31 | JSON.parse(response.body).dig("ok") 32 | else 33 | # https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks 34 | response.is_a?(Net::HTTPSuccess) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | 41 | ActiveSupport.run_load_hooks :noticed_delivery_methods_slack, Noticed::DeliveryMethods::Slack 42 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/test.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class Test < DeliveryMethod 4 | class_attribute :delivered, default: [] 5 | 6 | def deliver 7 | delivered << notification 8 | end 9 | end 10 | end 11 | end 12 | 13 | ActiveSupport.run_load_hooks :noticed_delivery_methods_test, Noticed::DeliveryMethods::Test 14 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/twilio_messaging.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class TwilioMessaging < DeliveryMethod 4 | def deliver 5 | post_request url, basic_auth: {user: account_sid, pass: auth_token}, form: json.stringify_keys 6 | rescue Noticed::ResponseUnsuccessful => exception 7 | if exception.response.code.start_with?("4") && config[:error_handler] 8 | notification.instance_exec(exception.response, &config[:error_handler]) 9 | else 10 | raise 11 | end 12 | end 13 | 14 | def json 15 | evaluate_option(:json) || { 16 | From: phone_number, 17 | To: recipient.phone_number, 18 | Body: params.fetch(:message) 19 | } 20 | end 21 | 22 | def url 23 | evaluate_option(:url) || "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}/Messages.json" 24 | end 25 | 26 | def account_sid 27 | evaluate_option(:account_sid) || credentials.fetch(:account_sid) 28 | end 29 | 30 | def auth_token 31 | evaluate_option(:auth_token) || credentials.fetch(:auth_token) 32 | end 33 | 34 | def phone_number 35 | evaluate_option(:phone_number) || credentials.fetch(:phone_number) 36 | end 37 | 38 | def credentials 39 | evaluate_option(:credentials) || Rails.application.credentials.twilio 40 | end 41 | end 42 | end 43 | end 44 | 45 | ActiveSupport.run_load_hooks :noticed_delivery_methods_twilio_messaging, Noticed::DeliveryMethods::TwilioMessaging 46 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/vonage_sms.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class VonageSms < DeliveryMethod 4 | DEFAULT_URL = "https://rest.nexmo.com/sms/json" 5 | 6 | required_options :json 7 | 8 | def deliver 9 | headers = evaluate_option(:headers) 10 | json = evaluate_option(:json) 11 | response = post_request url, headers: headers, json: json 12 | raise ResponseUnsuccessful.new(response, url, headers: headers, json: json) if JSON.parse(response.body).dig("messages", 0, "status") != "0" 13 | end 14 | 15 | def url 16 | evaluate_option(:url) || DEFAULT_URL 17 | end 18 | end 19 | end 20 | end 21 | 22 | ActiveSupport.run_load_hooks :noticed_delivery_methods_vonage_sms, Noticed::DeliveryMethods::VonageSms 23 | -------------------------------------------------------------------------------- /lib/noticed/delivery_methods/webhook.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module DeliveryMethods 3 | class Webhook < DeliveryMethod 4 | required_options :url 5 | 6 | def deliver 7 | post_request( 8 | evaluate_option(:url), 9 | basic_auth: evaluate_option(:basic_auth), 10 | headers: evaluate_option(:headers), 11 | json: evaluate_option(:json), 12 | form: evaluate_option(:form) 13 | ) 14 | end 15 | end 16 | end 17 | end 18 | 19 | ActiveSupport.run_load_hooks :noticed_delivery_methods_webhook, Noticed::DeliveryMethods::Webhook 20 | -------------------------------------------------------------------------------- /lib/noticed/engine.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class Engine < ::Rails::Engine 3 | isolate_namespace Noticed 4 | 5 | initializer "noticed.has_notifications" do 6 | ActiveSupport.on_load(:active_record) do 7 | include Noticed::HasNotifications 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/noticed/has_notifications.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module HasNotifications 3 | # Defines a method for the association and a before_destroy callback to remove notifications 4 | # where this record is a param 5 | # 6 | # class User < ApplicationRecord 7 | # has_noticed_notifications 8 | # has_noticed_notifications param_name: :owner, destroy: false, model: "Notification" 9 | # end 10 | # 11 | # @user.notifications_as_user 12 | # @user.notifications_as_owner 13 | 14 | extend ActiveSupport::Concern 15 | 16 | class_methods do 17 | def has_noticed_notifications(param_name: model_name.singular, **options) 18 | define_method :"notifications_as_#{param_name}" do 19 | model = options.fetch(:model_name, "Noticed::Event").constantize 20 | case current_adapter 21 | when "postgresql", "postgis" 22 | model.where("params @> ?", Noticed::Coder.dump(param_name.to_sym => self).to_json) 23 | when "mysql2", "trilogy" 24 | model.where("JSON_CONTAINS(params, ?)", Noticed::Coder.dump(param_name.to_sym => self).to_json) 25 | when "sqlite3" 26 | model.where("json_extract(params, ?) = ?", "$.#{param_name}", Noticed::Coder.dump(self).to_json) 27 | else 28 | # This will perform an exact match which isn't ideal 29 | model.where(params: {param_name.to_sym => self}) 30 | end 31 | end 32 | 33 | if options.fetch(:destroy, true) 34 | before_destroy do 35 | send(:"notifications_as_#{param_name}").destroy_all 36 | end 37 | end 38 | end 39 | end 40 | 41 | def current_adapter 42 | if ActiveRecord::Base.respond_to?(:connection_db_config) 43 | ActiveRecord::Base.connection_db_config.adapter 44 | else 45 | ActiveRecord::Base.connection_config[:adapter] 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/noticed/notification_channel.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | class NotificationChannel < ApplicationCable::Channel 3 | def subscribed 4 | stream_for current_user 5 | end 6 | 7 | def unsubscribed 8 | stop_all_streams 9 | end 10 | 11 | def mark_as_seen(data) 12 | current_user.notifications.where(id: data["ids"]).mark_as_seen 13 | end 14 | 15 | def mark_as_read(data) 16 | current_user.notifications.where(id: data["ids"]).mark_as_read 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/noticed/required_options.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module RequiredOptions 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | class_attribute :required_option_names, instance_writer: false, default: [] 7 | end 8 | 9 | class_methods do 10 | def inherited(base) 11 | base.required_option_names = required_option_names.dup 12 | super 13 | end 14 | 15 | def required_options(*names) 16 | required_option_names.concat names 17 | end 18 | alias_method :required_option, :required_options 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/noticed/translation.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | module Translation 3 | extend ActiveSupport::Concern 4 | 5 | # Returns the +i18n_scope+ for the class. Overwrite if you want custom lookup. 6 | def i18n_scope 7 | :notifiers 8 | end 9 | 10 | def class_scope 11 | self.class.name.underscore.tr("/", ".") 12 | end 13 | 14 | def translate(key, **options) 15 | if defined?(::ActiveSupport::HtmlSafeTranslation) 16 | ActiveSupport::HtmlSafeTranslation.translate scope_translation_key(key), **options 17 | else 18 | I18n.translate scope_translation_key(key), **options 19 | end 20 | end 21 | alias_method :t, :translate 22 | 23 | def scope_translation_key(key) 24 | if key.to_s.start_with?(".") 25 | [i18n_scope, class_scope].compact.join(".") + key 26 | else 27 | key 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/noticed/version.rb: -------------------------------------------------------------------------------- 1 | module Noticed 2 | VERSION = "2.7.0" 3 | end 4 | -------------------------------------------------------------------------------- /noticed.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("lib", __dir__) 2 | 3 | # Maintain your gem's version: 4 | require "noticed/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |spec| 8 | spec.name = "noticed" 9 | spec.version = Noticed::VERSION 10 | spec.authors = ["Chris Oliver"] 11 | spec.email = ["excid3@gmail.com"] 12 | spec.homepage = "https://github.com/excid3/noticed" 13 | spec.summary = "Notifications for Ruby on Rails applications" 14 | spec.description = "Database, browser, realtime ActionCable, Email, SMS, Slack notifications, and more for Rails apps" 15 | spec.license = "MIT" 16 | 17 | spec.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 18 | 19 | spec.add_dependency "rails", ">= 6.1.0" 20 | end 21 | -------------------------------------------------------------------------------- /test/bulk_delivery_methods/webhook_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class WebhookBulkDeliveryMethodTest < ActiveSupport::TestCase 4 | include ActiveJob::TestHelper 5 | 6 | setup do 7 | @delivery_method = Noticed::BulkDeliveryMethods::Webhook.new 8 | end 9 | 10 | test "end to end" do 11 | stub_request(:post, "https://example.org/bulk") 12 | perform_enqueued_jobs do 13 | BulkNotifier.deliver 14 | end 15 | end 16 | 17 | test "webhook with json payload" do 18 | set_config( 19 | url: "https://example.org/webhook", 20 | json: {foo: :bar} 21 | ) 22 | stub_request(:post, "https://example.org/webhook").with(body: "{\"foo\":\"bar\"}") 23 | 24 | assert_nothing_raised do 25 | @delivery_method.deliver 26 | end 27 | end 28 | 29 | test "webhook with form payload" do 30 | set_config( 31 | url: "https://example.org/webhook", 32 | form: {foo: :bar} 33 | ) 34 | stub_request(:post, "https://example.org/webhook").with(headers: {"Content-Type" => /application\/x-www-form-urlencoded/}) 35 | assert_nothing_raised do 36 | @delivery_method.deliver 37 | end 38 | end 39 | 40 | test "webhook with basic auth" do 41 | set_config( 42 | url: "https://example.org/webhook", 43 | basic_auth: {user: "username", pass: "password"} 44 | ) 45 | stub_request(:post, "https://example.org/webhook").with(headers: {"Authorization" => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="}) 46 | assert_nothing_raised do 47 | @delivery_method.deliver 48 | end 49 | end 50 | 51 | test "webhook with headers" do 52 | set_config( 53 | url: "https://example.org/webhook", 54 | headers: {"Content-Type" => "application/json"} 55 | ) 56 | stub_request(:post, "https://example.org/webhook").with(headers: {"Content-Type" => "application/json"}) 57 | 58 | assert_nothing_raised do 59 | @delivery_method.deliver 60 | end 61 | end 62 | 63 | test "webhook raises error with unsuccessful status codes" do 64 | set_config(url: "https://example.org/webhook") 65 | stub_request(:post, "https://example.org/webhook").to_return(status: 422) 66 | assert_raises Noticed::ResponseUnsuccessful do 67 | @delivery_method.deliver 68 | end 69 | end 70 | 71 | private 72 | 73 | def set_config(config) 74 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/delivery_method_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class DeliveryMethodTest < ActiveSupport::TestCase 4 | class InheritedDeliveryMethod < Noticed::DeliveryMethods::ActionCable 5 | end 6 | 7 | test "fetch_constant looks up constants from String" do 8 | @delivery_method = Noticed::DeliveryMethod.new 9 | set_config(mailer: "UserMailer") 10 | assert_equal UserMailer, @delivery_method.fetch_constant(:mailer) 11 | end 12 | 13 | test "fetch_constant looks up constants from proc that returns String" do 14 | @delivery_method = Noticed::DeliveryMethod.new 15 | set_config(mailer: -> { "UserMailer" }) 16 | assert_equal UserMailer, @delivery_method.fetch_constant(:mailer) 17 | end 18 | 19 | test "delivery methods inherit required options" do 20 | assert_equal [:message], InheritedDeliveryMethod.required_option_names 21 | end 22 | 23 | test "if config" do 24 | event = TestNotifier.deliver(User.first) 25 | notification = event.notifications.first 26 | delivery_method = Noticed::DeliveryMethods::Test.new 27 | 28 | assert delivery_method.perform(:test, notification, overrides: {if: true}) 29 | assert delivery_method.perform(:test, notification, overrides: {if: -> { unread? }}) 30 | refute delivery_method.perform(:test, notification, overrides: {if: false}) 31 | end 32 | 33 | test "unless overrides" do 34 | event = TestNotifier.deliver(User.first) 35 | notification = event.notifications.first 36 | delivery_method = Noticed::DeliveryMethods::Test.new 37 | 38 | refute delivery_method.perform(:test, notification, overrides: {unless: true}) 39 | assert delivery_method.perform(:test, notification, overrides: {unless: false}) 40 | assert delivery_method.perform(:test, notification, overrides: {unless: -> { read? }}) 41 | end 42 | 43 | test "passes notification when calling methods on Event" do 44 | notification = noticed_notifications(:one) 45 | event = notification.event 46 | 47 | def event.example_method(notification) 48 | @example = notification 49 | end 50 | 51 | delivery_method = Noticed::DeliveryMethods::Test.new 52 | delivery_method.instance_variable_set :@notification, notification 53 | delivery_method.instance_variable_set :@event, event 54 | delivery_method.instance_variable_set :@config, {if: :example_method} 55 | delivery_method.evaluate_option(:if) 56 | 57 | assert_equal notification, event.instance_variable_get(:@example) 58 | end 59 | 60 | class CallbackDeliveryMethod < Noticed::DeliveryMethod 61 | before_deliver :set_message 62 | attr_reader :message 63 | 64 | def set_message 65 | @message = "new message" 66 | end 67 | 68 | def deliver 69 | end 70 | end 71 | 72 | class CallbackBulkDeliveryMethod < Noticed::BulkDeliveryMethod 73 | before_deliver :set_message 74 | attr_reader :message 75 | 76 | def set_message 77 | @message = "new message" 78 | end 79 | 80 | def deliver 81 | end 82 | end 83 | 84 | class CallbackNotifier < Noticed::Event 85 | deliver_by :test 86 | end 87 | 88 | class CallbackBulkNotifier < Noticed::Event 89 | bulk_deliver_by :test 90 | end 91 | 92 | test "calls callbacks" do 93 | event = CallbackNotifier.with(message: "test") 94 | notification = Noticed::Notification.create(recipient: User.first, event: event) 95 | delivery_method = CallbackDeliveryMethod.new 96 | delivery_method.perform(:test, notification) 97 | assert_equal delivery_method.message, "new message" 98 | end 99 | 100 | test "calls callbacks for bulk delivery" do 101 | event = CallbackBulkNotifier.with(message: "test") 102 | delivery_method = CallbackBulkDeliveryMethod.new 103 | delivery_method.perform(:test, event) 104 | assert_equal delivery_method.message, "new message" 105 | end 106 | 107 | private 108 | 109 | def set_config(config) 110 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/delivery_methods/action_cable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActionCableDeliveryMethodTest < ActiveSupport::TestCase 4 | include ActionCable::TestHelper 5 | 6 | setup do 7 | @delivery_method = Noticed::DeliveryMethods::ActionCable.new 8 | end 9 | 10 | test "sends websocket message" do 11 | user = users(:one) 12 | channel = Noticed::NotificationChannel.broadcasting_for(user) 13 | 14 | set_config( 15 | channel: "Noticed::NotificationChannel", 16 | stream: user, 17 | message: {foo: :bar} 18 | ) 19 | 20 | assert_broadcasts(channel, 1) do 21 | @delivery_method.deliver 22 | end 23 | end 24 | 25 | test "default channel" do 26 | set_config({}) 27 | assert_equal Noticed::NotificationChannel, @delivery_method.channel 28 | end 29 | 30 | test "default stream" do 31 | notification = noticed_notifications(:one) 32 | set_config({}) 33 | @delivery_method.instance_variable_set :@notification, notification 34 | assert_equal notification.recipient, @delivery_method.stream 35 | end 36 | 37 | private 38 | 39 | def set_config(config) 40 | @delivery_method.instance_variable_set :@config, ActiveSupport::OrderedOptions.new.merge(config) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/delivery_methods/email_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class EmailTest < ActiveSupport::TestCase 4 | include ActionMailer::TestHelper 5 | 6 | setup do 7 | @delivery_method = Noticed::DeliveryMethods::Email.new 8 | @notification = noticed_notifications(:one) 9 | end 10 | 11 | test "sends email" do 12 | set_config( 13 | mailer: "UserMailer", 14 | method: "new_comment", 15 | params: -> { {foo: :bar} }, 16 | args: -> { ["hey"] } 17 | ) 18 | 19 | assert_emails(1) do 20 | @delivery_method.deliver 21 | end 22 | end 23 | 24 | test "enqueues email" do 25 | set_config( 26 | mailer: "UserMailer", 27 | method: "receipt", 28 | enqueue: true 29 | ) 30 | 31 | assert_enqueued_emails(1) do 32 | @delivery_method.deliver 33 | end 34 | end 35 | 36 | test "includes notification in params" do 37 | set_config(mailer: "UserMailer", method: "new_comment") 38 | assert_equal @notification, @delivery_method.params.fetch(:notification) 39 | end 40 | 41 | test "includes record in params" do 42 | set_config(mailer: "UserMailer", method: "new_comment") 43 | assert_equal @notification.record, @delivery_method.params.fetch(:record) 44 | end 45 | 46 | test "includes recipient in params" do 47 | set_config(mailer: "UserMailer", method: "new_comment") 48 | assert_equal @notification.recipient, @delivery_method.params.fetch(:recipient) 49 | end 50 | 51 | private 52 | 53 | def set_config(config) 54 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 55 | @delivery_method.instance_variable_set :@notification, @notification 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/delivery_methods/fcm_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FcmTest < ActiveSupport::TestCase 4 | class FakeAuthorizer 5 | def self.make_creds(options = {}) 6 | new 7 | end 8 | 9 | def fetch_access_token! 10 | {"access_token" => "access-token-12341234"} 11 | end 12 | end 13 | 14 | setup do 15 | @delivery_method = Noticed::DeliveryMethods::Fcm.new 16 | end 17 | 18 | test "notifies each device token" do 19 | set_config( 20 | authorizer: FakeAuthorizer, 21 | credentials: { 22 | "type" => "service_account", 23 | "project_id" => "p_1234", 24 | "private_key_id" => "private_key" 25 | }, 26 | device_tokens: [:a, :b], 27 | json: ->(device_token) { 28 | { 29 | message: { 30 | token: device_token, 31 | notification: {title: "Title", body: "Body"} 32 | } 33 | } 34 | } 35 | ) 36 | 37 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").with(body: "{\"message\":{\"token\":\"a\",\"notification\":{\"title\":\"Title\",\"body\":\"Body\"}}}") 38 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").with(body: "{\"message\":{\"token\":\"b\",\"notification\":{\"title\":\"Title\",\"body\":\"Body\"}}}") 39 | 40 | assert_nothing_raised do 41 | @delivery_method.deliver 42 | end 43 | end 44 | 45 | test "notifies of invalid tokens for clean up" do 46 | cleanups = 0 47 | 48 | set_config( 49 | authorizer: FakeAuthorizer, 50 | credentials: { 51 | "type" => "service_account", 52 | "project_id" => "p_1234", 53 | "private_key_id" => "private_key" 54 | }, 55 | device_tokens: [:a, :b], 56 | json: ->(device_token) { 57 | { 58 | message: { 59 | token: device_token, 60 | notification: {title: "Title", body: "Body"} 61 | } 62 | } 63 | }, 64 | invalid_token: ->(device_token) { cleanups += 1 } 65 | ) 66 | 67 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").to_return(status: 404, body: "", headers: {}) 68 | 69 | @delivery_method.deliver 70 | assert_equal 2, cleanups 71 | end 72 | 73 | test "notifies of unregistered tokens for clean up" do 74 | cleanups = 0 75 | 76 | set_config( 77 | authorizer: FakeAuthorizer, 78 | credentials: { 79 | "type" => "service_account", 80 | "project_id" => "p_1234", 81 | "private_key_id" => "private_key" 82 | }, 83 | device_tokens: [:a, :b], 84 | json: ->(device_token) { 85 | { 86 | message: { 87 | token: device_token, 88 | notification: {title: "Title", body: "Body"} 89 | } 90 | } 91 | }, 92 | invalid_token: ->(device_token) { cleanups += 1 } 93 | ) 94 | 95 | stub_request(:post, "https://fcm.googleapis.com/v1/projects/p_1234/messages:send").to_return(status: 400, body: "", headers: {}) 96 | 97 | @delivery_method.deliver 98 | assert_equal 2, cleanups 99 | end 100 | 101 | private 102 | 103 | def set_config(config) 104 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/delivery_methods/ios_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class IosTest < ActiveSupport::TestCase 4 | class FakeConnectionPool 5 | class_attribute :invalid_tokens, default: [] 6 | attr_reader :deliveries 7 | 8 | def initialize(response) 9 | @response = response 10 | @deliveries = [] 11 | end 12 | 13 | def with 14 | yield self 15 | end 16 | 17 | def push(apn) 18 | @deliveries.push(apn) 19 | @response 20 | end 21 | 22 | def close 23 | end 24 | end 25 | 26 | class FakeResponse 27 | attr_reader :status 28 | 29 | def initialize(status, body = {}) 30 | @status = status 31 | end 32 | 33 | def ok? 34 | status.start_with?("20") 35 | end 36 | end 37 | 38 | setup do 39 | FakeConnectionPool.invalid_tokens = [] 40 | 41 | @delivery_method = Noticed::DeliveryMethods::Ios.new 42 | @delivery_method.instance_variable_set :@notification, noticed_notifications(:one) 43 | set_config( 44 | bundle_identifier: "bundle_id", 45 | key_id: "key_id", 46 | team_id: "team_id", 47 | apns_key: "apns_key", 48 | device_tokens: [:a, :b], 49 | format: ->(apn) { 50 | apn.alert = "Hello world" 51 | apn.custom_payload = {url: root_url(host: "example.org")} 52 | }, 53 | invalid_token: ->(device_token) { 54 | FakeConnectionPool.invalid_tokens << device_token 55 | } 56 | ) 57 | end 58 | 59 | test "notifies each device token" do 60 | connection_pool = FakeConnectionPool.new(FakeResponse.new("200")) 61 | @delivery_method.stub(:production_pool, connection_pool) do 62 | @delivery_method.deliver 63 | end 64 | 65 | assert_equal 2, connection_pool.deliveries.count 66 | assert_equal 0, FakeConnectionPool.invalid_tokens.count 67 | end 68 | 69 | test "notifies of invalid tokens for cleanup" do 70 | connection_pool = FakeConnectionPool.new(FakeResponse.new("410")) 71 | @delivery_method.stub(:production_pool, connection_pool) do 72 | @delivery_method.deliver 73 | end 74 | 75 | # Our fake connection pool doesn't understand these wouldn't be delivered in the real world 76 | assert_equal 2, connection_pool.deliveries.count 77 | assert_equal 2, FakeConnectionPool.invalid_tokens.count 78 | end 79 | 80 | private 81 | 82 | def set_config(config) 83 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/delivery_methods/microsoft_teams_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MicrosoftTeamsTest < ActiveSupport::TestCase 4 | setup do 5 | @delivery_method = Noticed::DeliveryMethods::MicrosoftTeams.new 6 | set_config( 7 | json: {foo: :bar}, 8 | url: "https://teams.microsoft.com" 9 | ) 10 | end 11 | 12 | test "sends a message" do 13 | stub_request(:post, "https://teams.microsoft.com").with(body: "{\"foo\":\"bar\"}") 14 | assert_nothing_raised do 15 | @delivery_method.deliver 16 | end 17 | end 18 | 19 | test "raises error on failure" do 20 | stub_request(:post, "https://teams.microsoft.com").to_return(status: 422) 21 | assert_raises Noticed::ResponseUnsuccessful do 22 | @delivery_method.deliver 23 | end 24 | end 25 | 26 | private 27 | 28 | def set_config(config) 29 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/delivery_methods/slack_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SlackTest < ActiveSupport::TestCase 4 | setup do 5 | @delivery_method = Noticed::DeliveryMethods::Slack.new 6 | set_config(json: {foo: :bar}) 7 | end 8 | 9 | test "sends a slack message with application/json content type" do 10 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL) 11 | .with( 12 | body: "{\"foo\":\"bar\"}", 13 | headers: {"Content-Type" => "application/json"} 14 | ) 15 | .to_return( 16 | status: 200, 17 | body: {ok: true}.to_json, 18 | headers: {"Content-Type" => "application/json"} 19 | ) 20 | 21 | assert_nothing_raised do 22 | @delivery_method.deliver 23 | end 24 | end 25 | 26 | test "sends a slack message with text/html content type" do 27 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL) 28 | .with( 29 | body: "{\"foo\":\"bar\"}", 30 | headers: {"Content-Type" => "application/json"} 31 | ) 32 | .to_return( 33 | status: 200, 34 | body: "<html><body>ok</body></html>", 35 | headers: {"Content-Type" => "text/html"} 36 | ) 37 | 38 | assert_nothing_raised do 39 | @delivery_method.deliver 40 | end 41 | end 42 | 43 | test "raises error on failure" do 44 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL).to_return(status: 422) 45 | assert_raises Noticed::ResponseUnsuccessful do 46 | @delivery_method.deliver 47 | end 48 | end 49 | 50 | test "doesnt raise error on failed 200 status code request with raise_if_not_ok false" do 51 | @delivery_method.config[:raise_if_not_ok] = false 52 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL).with(body: "{\"foo\":\"bar\"}").to_return(status: 200, body: {ok: false}.to_json, headers: {"Content-Type" => "application/json"}) 53 | assert_nothing_raised do 54 | @delivery_method.deliver 55 | end 56 | end 57 | 58 | test "raises error on 200 status code request with raise_if_not_ok true" do 59 | @delivery_method.config[:raise_if_not_ok] = true 60 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL).with(body: "{\"foo\":\"bar\"}").to_return(status: 200, body: {ok: false}.to_json, headers: {"Content-Type" => "application/json"}) 61 | assert_raises Noticed::ResponseUnsuccessful do 62 | @delivery_method.deliver 63 | end 64 | end 65 | 66 | test "raises error on 400 status code request with raise_if_not_ok true" do 67 | @delivery_method.config[:raise_if_not_ok] = true 68 | stub_request(:post, Noticed::DeliveryMethods::Slack::DEFAULT_URL).with(body: "{\"foo\":\"bar\"}").to_return(status: 403, headers: {"Content-Type" => "text/html"}) 69 | assert_raises Noticed::ResponseUnsuccessful do 70 | @delivery_method.deliver 71 | end 72 | end 73 | 74 | private 75 | 76 | def set_config(config) 77 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/delivery_methods/twilio_messaging_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TwilioMessagingTest < ActiveSupport::TestCase 4 | setup do 5 | @delivery_method = Noticed::DeliveryMethods::TwilioMessaging.new 6 | @config = { 7 | account_sid: "acct_1234", 8 | auth_token: "token", 9 | json: -> { 10 | { 11 | From: "+1234567890", 12 | To: "+1234567890", 13 | Body: "Hello world" 14 | } 15 | } 16 | } 17 | set_config(@config) 18 | end 19 | 20 | test "sends sms" do 21 | stub_request(:post, "https://api.twilio.com/2010-04-01/Accounts/acct_1234/Messages.json").with( 22 | headers: { 23 | "Authorization" => "Basic YWNjdF8xMjM0OnRva2Vu", 24 | "Content-Type" => "application/x-www-form-urlencoded" 25 | }, 26 | body: { 27 | From: "+1234567890", 28 | To: "+1234567890", 29 | Body: "Hello world" 30 | } 31 | ).to_return(status: 200) 32 | 33 | assert_nothing_raised do 34 | @delivery_method.deliver 35 | end 36 | end 37 | 38 | test "raises error on failure" do 39 | stub_request(:post, "https://api.twilio.com/2010-04-01/Accounts/acct_1234/Messages.json").to_return(status: 422) 40 | assert_raises Noticed::ResponseUnsuccessful do 41 | @delivery_method.deliver 42 | end 43 | end 44 | 45 | test "passes error to notification instance if error_handler is configured" do 46 | @delivery_method = Noticed::DeliveryMethods::TwilioMessaging.new( 47 | "delivery_method_name", 48 | noticed_notifications(:one) 49 | ) 50 | 51 | error_handler_called = false 52 | @config[:error_handler] = lambda do |twilio_error_message| 53 | error_handler_called = true 54 | end 55 | set_config(@config) 56 | 57 | stub_request(:post, "https://api.twilio.com/2010-04-01/Accounts/acct_1234/Messages.json").to_return(status: 422) 58 | assert_nothing_raised do 59 | @delivery_method.deliver 60 | end 61 | 62 | assert(error_handler_called, "Handler is called if status is 4xx") 63 | end 64 | 65 | private 66 | 67 | def set_config(config) 68 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/delivery_methods/vonage_sms_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class VonageSmsTest < ActiveSupport::TestCase 4 | setup do 5 | @delivery_method = Noticed::DeliveryMethods::VonageSms.new 6 | end 7 | 8 | test "sends sms" do 9 | set_config(json: {foo: :bar}) 10 | stub_request(:post, Noticed::DeliveryMethods::VonageSms::DEFAULT_URL).with(body: "{\"foo\":\"bar\"}").to_return(status: 200, body: "{\"messages\":[{\"status\":\"0\"}]}") 11 | assert_nothing_raised do 12 | @delivery_method.deliver 13 | end 14 | end 15 | 16 | test "raises error on failure" do 17 | set_config(json: {foo: :bar}) 18 | stub_request(:post, Noticed::DeliveryMethods::VonageSms::DEFAULT_URL).to_return(status: 200, body: "{\"messages\":[{\"status\":\"1\"}]}") 19 | assert_raises Noticed::ResponseUnsuccessful do 20 | @delivery_method.deliver 21 | end 22 | end 23 | 24 | private 25 | 26 | def set_config(config) 27 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/delivery_methods/webhook_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class WebhookDeliveryMethodTest < ActiveSupport::TestCase 4 | setup do 5 | @delivery_method = Noticed::DeliveryMethods::Webhook.new 6 | end 7 | 8 | test "webhook with json payload" do 9 | set_config( 10 | url: "https://example.org/webhook", 11 | json: {foo: :bar} 12 | ) 13 | stub_request(:post, "https://example.org/webhook").with(body: "{\"foo\":\"bar\"}") 14 | 15 | assert_nothing_raised do 16 | @delivery_method.deliver 17 | end 18 | end 19 | 20 | test "webhook with form payload" do 21 | set_config( 22 | url: "https://example.org/webhook", 23 | form: {foo: :bar} 24 | ) 25 | stub_request(:post, "https://example.org/webhook").with(headers: {"Content-Type" => /application\/x-www-form-urlencoded/}) 26 | 27 | assert_nothing_raised do 28 | @delivery_method.deliver 29 | end 30 | end 31 | 32 | test "webhook with basic auth" do 33 | set_config( 34 | url: "https://example.org/webhook", 35 | basic_auth: {user: "username", pass: "password"} 36 | ) 37 | stub_request(:post, "https://example.org/webhook").with(headers: {"Authorization" => "Basic dXNlcm5hbWU6cGFzc3dvcmQ="}) 38 | 39 | assert_nothing_raised do 40 | @delivery_method.deliver 41 | end 42 | end 43 | 44 | test "webhook with headers" do 45 | set_config( 46 | url: "https://example.org/webhook", 47 | headers: {"Content-Type" => "application/json"} 48 | ) 49 | stub_request(:post, "https://example.org/webhook").with(headers: {"Content-Type" => "application/json"}) 50 | 51 | assert_nothing_raised do 52 | @delivery_method.deliver 53 | end 54 | end 55 | 56 | test "webhook raises error with unsuccessful status codes" do 57 | set_config(url: "https://example.org/webhook") 58 | stub_request(:post, "https://example.org/webhook").to_return(status: 422) 59 | assert_raises Noticed::ResponseUnsuccessful do 60 | @delivery_method.deliver 61 | end 62 | end 63 | 64 | private 65 | 66 | def set_config(config) 67 | @delivery_method.instance_variable_set :@config, ActiveSupport::HashWithIndifferentAccess.new(config) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /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_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /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/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | def connect 4 | self.current_user = find_verified_user 5 | logger.add_tags "ActionCable", "User #{current_user.id}" 6 | end 7 | 8 | protected 9 | 10 | def find_verified_user 11 | if (current_user = env["warden"].user(:user)) 12 | current_user 13 | else 14 | reject_unauthorized_connection 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com", to: "to@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/user_mailer.rb: -------------------------------------------------------------------------------- 1 | class UserMailer < ApplicationMailer 2 | def new_comment(*args) 3 | mail(body: "new comment") 4 | end 5 | 6 | def receipt 7 | mail(body: "receipt") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/app/models/account.rb: -------------------------------------------------------------------------------- 1 | class Account < ApplicationRecord 2 | has_many :notifications, as: :record, dependent: :destroy, class_name: "Noticed::Notification" 3 | has_many :notifiers, as: :record, dependent: :destroy, class_name: "Noticed::Event" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/admin.rb: -------------------------------------------------------------------------------- 1 | class Admin < User 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | if respond_to?(:primary_abstract_class) 3 | primary_abstract_class 4 | else 5 | self.abstract_class = true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification" 3 | 4 | # Used for querying Noticed::Event where params[:user] is a User instance 5 | has_noticed_notifications 6 | has_noticed_notifications param_name: :owner, destroy: false 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/application_delivery_method.rb: -------------------------------------------------------------------------------- 1 | class ApplicationDeliveryMethod < Noticed::DeliveryMethod 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/application_notifier.rb: -------------------------------------------------------------------------------- 1 | class ApplicationNotifier < Noticed::Event 2 | notification_methods do 3 | def inherited_method 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/bulk_notifier.rb: -------------------------------------------------------------------------------- 1 | class BulkNotifier < ApplicationNotifier 2 | bulk_deliver_by :webhook, url: "https://example.org/bulk" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/comment_notifier.rb: -------------------------------------------------------------------------------- 1 | class CommentNotifier < ApplicationNotifier 2 | recipients -> { params[:recipients] } 3 | 4 | deliver_by :test 5 | 6 | # delivery_by :email, mailer: "UserMailer", method: "new_comment" 7 | deliver_by :email do |config| 8 | config.mailer = "UserMailer" 9 | config.method = :new_comment 10 | end 11 | 12 | deliver_by :action_cable do |config| 13 | config.channel = "Noticed::NotificationChannel" 14 | config.stream = -> { recipient } 15 | config.message = -> { params } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/deprecated_notifier.rb: -------------------------------------------------------------------------------- 1 | class DeprecatedNotifier < Noticed::Base 2 | param :message 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/ephemeral_notifier.rb: -------------------------------------------------------------------------------- 1 | class EphemeralNotifier < Noticed::Ephemeral 2 | bulk_deliver_by :test 3 | 4 | deliver_by :test do |config| 5 | config.wait = 5.minutes 6 | end 7 | 8 | deliver_by :email do |config| 9 | config.mailer = "UserMailer" 10 | config.method = "new_comment" 11 | config.args = :email_args 12 | config.params = -> { {recipient: recipient} } 13 | end 14 | 15 | def email_args(recipient) 16 | [recipient] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/inherited_notifier.rb: -------------------------------------------------------------------------------- 1 | class InheritedNotifier < SimpleNotifier 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/priority_notifier.rb: -------------------------------------------------------------------------------- 1 | class PriorityNotifier < ApplicationNotifier 2 | deliver_by :test, priority: 2 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/queue_notifier.rb: -------------------------------------------------------------------------------- 1 | class QueueNotifier < ApplicationNotifier 2 | deliver_by :test, queue: :example_queue 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/receipt_notifier.rb: -------------------------------------------------------------------------------- 1 | class ReceiptNotifier < ApplicationNotifier 2 | deliver_by :test 3 | 4 | deliver_by :email do |config| 5 | config.mailer = "UserMailer" 6 | config.method = :receipt 7 | config.params = -> { params } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/record_notifier.rb: -------------------------------------------------------------------------------- 1 | class RecordNotifier < ApplicationNotifier 2 | validates :record, presence: true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/simple_notifier.rb: -------------------------------------------------------------------------------- 1 | class SimpleNotifier < ApplicationNotifier 2 | deliver_by :test 3 | required_params :message 4 | 5 | def url 6 | root_url 7 | end 8 | 9 | notification_methods do 10 | def message 11 | "hello #{recipient.email}" 12 | end 13 | 14 | def url 15 | root_url 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/test_notifier.rb: -------------------------------------------------------------------------------- 1 | class TestNotifier < ApplicationNotifier 2 | deliver_by :test 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/wait_notifier.rb: -------------------------------------------------------------------------------- 1 | class WaitNotifier < ApplicationNotifier 2 | deliver_by :test, wait: 5.minutes 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/notifiers/wait_until_notifier.rb: -------------------------------------------------------------------------------- 1 | class WaitUntilNotifier < ApplicationNotifier 2 | deliver_by :test, wait_until: -> { 1.hour.from_now } 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <title>Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag "application" %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /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/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 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 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 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 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/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 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | config.load_defaults Rails::VERSION::STRING.to_f 12 | 13 | # For compatibility with applications that use this config 14 | config.action_controller.include_all_helpers = false 15 | 16 | # Please, add to the `ignore` list any other `lib` subdirectories that do 17 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 18 | # Common ones are `templates`, `generators`, or `middleware`, for example. 19 | # config.autoload_lib(ignore: %w[assets tasks]) 20 | 21 | # Configuration for the application, engines, and railties goes here. 22 | # 23 | # These settings can be overridden in specific environments using the files 24 | # in config/environments, which are processed later. 25 | # 26 | # config.time_zone = "Central Time (US & Canada)" 27 | # config.eager_load_paths << Rails.root.join("extras") 28 | 29 | config.active_job.queue_adapter = :test 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /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/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 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: storage/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: storage/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: storage/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 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.enable_reloading = true 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Highlight code that enqueued background job in logs. 60 | config.active_job.verbose_enqueue_logs = true 61 | 62 | # Suppress logger output for asset requests. 63 | # config.assets.quiet = true 64 | 65 | # Raises error for missing translations. 66 | # config.i18n.raise_on_missing_translations = true 67 | 68 | # Annotate rendered view with file names. 69 | # config.action_view.annotate_rendered_view_with_filenames = true 70 | 71 | # Uncomment if you wish to allow Action Cable access from any origin. 72 | # config.action_cable.disable_request_forgery_protection = true 73 | 74 | # Raise error when a before_action's only/except options reference missing actions 75 | config.action_controller.raise_on_missing_callback_actions = true 76 | 77 | routes.default_url_options[:host] = "localhost:3000" 78 | end 79 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment 20 | # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. 24 | # config.public_file_server.enabled = false 25 | 26 | # Compress CSS using a preprocessor. 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 33 | # config.asset_host = "http://assets.example.com" 34 | 35 | # Specifies the header that your server uses for sending files. 36 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 37 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 38 | 39 | # Store uploaded files on the local file system (see config/storage.yml for options). 40 | config.active_storage.service = :local 41 | 42 | # Mount Action Cable outside main process or domain. 43 | # config.action_cable.mount_path = nil 44 | # config.action_cable.url = "wss://example.com/cable" 45 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 46 | 47 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 48 | # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. 49 | # config.assume_ssl = true 50 | 51 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 52 | config.force_ssl = true 53 | 54 | # Log to STDOUT by default 55 | config.logger = ActiveSupport::Logger.new($stdout) 56 | .tap { |logger| logger.formatter = ::Logger::Formatter.new } 57 | .then { |logger| ActiveSupport::TaggedLogging.new(logger) } 58 | 59 | # Prepend all log lines with the following tags. 60 | config.log_tags = [:request_id] 61 | 62 | # Info include generic and useful information about system operation, but avoids logging too much 63 | # information to avoid inadvertent exposure of personally identifiable information (PII). If you 64 | # want to log everything, set the level to "debug". 65 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 66 | 67 | # Use a different cache store in production. 68 | # config.cache_store = :mem_cache_store 69 | 70 | # Use a real queuing backend for Active Job (and separate queues per environment). 71 | # config.active_job.queue_adapter = :resque 72 | # config.active_job.queue_name_prefix = "dummy_production" 73 | 74 | config.action_mailer.perform_caching = false 75 | 76 | # Ignore bad email addresses and do not raise email delivery errors. 77 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 78 | # config.action_mailer.raise_delivery_errors = false 79 | 80 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 81 | # the I18n.default_locale when a translation cannot be found). 82 | config.i18n.fallbacks = true 83 | 84 | # Don't log any deprecations. 85 | config.active_support.report_deprecations = false 86 | 87 | # Do not dump schema after migrations. 88 | config.active_record.dump_schema_after_migration = false 89 | 90 | # Enable DNS rebinding protection and other `Host` header attacks. 91 | # config.hosts = [ 92 | # "example.com", # Allow requests from example.com 93 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 94 | # ] 95 | # Skip DNS rebinding protection for the default health check endpoint. 96 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 97 | end 98 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # While tests run files are not watched, reloading is not necessary. 12 | config.enable_reloading = false 13 | 14 | # Eager loading loads your entire application. When running a single test locally, 15 | # this is usually not necessary, and can slow down your test suite. However, it's 16 | # recommended that you enable it in continuous integration systems to ensure eager 17 | # loading is working properly before deploying your code. 18 | config.eager_load = ENV["CI"].present? 19 | 20 | # Configure public file server for tests with Cache-Control for performance. 21 | config.public_file_server.enabled = true 22 | config.public_file_server.headers = { 23 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 24 | } 25 | 26 | # Show full error reports and disable caching. 27 | config.consider_all_requests_local = true 28 | config.action_controller.perform_caching = false 29 | config.cache_store = :null_store 30 | 31 | # Render exception templates for rescuable exceptions and raise for other exceptions. 32 | config.action_dispatch.show_exceptions = :rescuable 33 | 34 | # Disable request forgery protection in test environment. 35 | config.action_controller.allow_forgery_protection = false 36 | 37 | # Store uploaded files on the local file system in a temporary directory. 38 | config.active_storage.service = :test 39 | 40 | config.action_mailer.perform_caching = false 41 | 42 | # Tell Action Mailer not to deliver emails to the real world. 43 | # The :test delivery method accumulates sent emails in the 44 | # ActionMailer::Base.deliveries array. 45 | config.action_mailer.delivery_method = :test 46 | 47 | # Print deprecation notices to the stderr. 48 | config.active_support.deprecation = :stderr 49 | 50 | # Raise exceptions for disallowed deprecations. 51 | config.active_support.disallowed_deprecation = :raise 52 | 53 | # Tell Active Support which deprecation messages to disallow. 54 | config.active_support.disallowed_deprecation_warnings = [] 55 | 56 | # Raises error for missing translations. 57 | # config.i18n.raise_on_missing_translations = true 58 | 59 | # Annotate rendered view with file names. 60 | # config.action_view.annotate_rendered_view_with_filenames = true 61 | 62 | # Raise error when a before_action's only/except options reference missing actions 63 | # config.action_controller.raise_on_missing_callback_actions = true 64 | 65 | routes.default_url_options[:host] = "localhost:3000" 66 | end 67 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | # Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /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/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # 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 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | message_html: "

Hello world

" 33 | notifiers: 34 | noticed: 35 | i18n_example: 36 | message: "This is a notification" 37 | noticed: 38 | scoped_i18n_example: 39 | message: "This is a custom scoped translation" 40 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | 5 | # Puma can serve each request in a thread from an internal thread pool. 6 | # The `threads` method setting takes two numbers: a minimum and maximum. 7 | # Any libraries that use thread pools should be configured to match 8 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 9 | # and maximum; this matches the default thread size of Active Record. 10 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 11 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 12 | threads min_threads_count, max_threads_count 13 | 14 | # Specifies that the worker count should equal the number of processors in production. 15 | if ENV["RAILS_ENV"] == "production" 16 | require "concurrent-ruby" 17 | worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) 18 | workers worker_count if worker_count > 1 19 | end 20 | 21 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 22 | # terminating a worker in development environments. 23 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 24 | 25 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 26 | port ENV.fetch("PORT") { 3000 } 27 | 28 | # Specifies the `environment` that Puma will run in. 29 | environment ENV.fetch("RAILS_ENV") { "development" } 30 | 31 | # Specifies the `pidfile` that Puma will use. 32 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 33 | 34 | # Allow puma to be restarted by `bin/rails restart` command. 35 | plugin :tmp_restart 36 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 3 | 4 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 5 | # Can be used by load balancers and uptime monitors to verify that the app is live. 6 | get "up" => "rails/health#show", :as => :rails_health_check 7 | 8 | # Defines the root path route ("/") 9 | root "posts#index" 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20231215202921_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :users do |t| 4 | t.string :type 5 | t.string :email 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20231215202924_create_accounts.rb: -------------------------------------------------------------------------------- 1 | class CreateAccounts < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :accounts do |t| 4 | t.string :name 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /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 `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/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: 2024_01_29_184740) do 14 | create_table "accounts", force: :cascade do |t| 15 | t.string "name" 16 | t.datetime "created_at", null: false 17 | t.datetime "updated_at", null: false 18 | end 19 | 20 | create_table "noticed_events", force: :cascade do |t| 21 | t.string "type" 22 | t.string "record_type" 23 | t.integer "record_id" 24 | if t.respond_to?(:jsonb) 25 | t.jsonb "params" 26 | else 27 | t.json "params" 28 | end 29 | t.integer "notifications_count" 30 | t.datetime "created_at", null: false 31 | t.datetime "updated_at", null: false 32 | t.index ["record_type", "record_id"], name: "index_noticed_events_on_record" 33 | end 34 | 35 | create_table "noticed_notifications", force: :cascade do |t| 36 | t.string "type" 37 | t.integer "event_id", null: false 38 | t.string "recipient_type", null: false 39 | t.integer "recipient_id", null: false 40 | t.datetime "read_at" 41 | t.datetime "seen_at" 42 | t.datetime "created_at", null: false 43 | t.datetime "updated_at", null: false 44 | t.index ["event_id"], name: "index_noticed_notifications_on_event_id" 45 | t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient" 46 | end 47 | 48 | create_table "users", force: :cascade do |t| 49 | t.string "type" 50 | t.string "email" 51 | t.datetime "created_at", null: false 52 | t.datetime "updated_at", null: false 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/dummy/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/dummy/public/apple-touch-icon.png -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/dummy/public/favicon.ico -------------------------------------------------------------------------------- /test/ephemeral_notifier_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class EphemeralNotifierTest < ActiveSupport::TestCase 4 | include ActionMailer::TestHelper 5 | include ActiveJob::TestHelper 6 | 7 | test "can enqueue delivery methods" do 8 | assert_enqueued_jobs 3 do 9 | EphemeralNotifier.new(params: {foo: :bar}).deliver(User.last) 10 | end 11 | 12 | assert_emails 1 do 13 | perform_enqueued_jobs 14 | end 15 | end 16 | 17 | test "ephemeral has record shortcut" do 18 | assert_equal :foo, EphemeralNotifier.with(record: :foo).record 19 | end 20 | 21 | test "ephemeral notifier includes Rails urls" do 22 | assert_equal "http://localhost:3000/", EphemeralNotifier.new.root_url 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/fixtures/accounts.yml: -------------------------------------------------------------------------------- 1 | one: 2 | name: Account One 3 | 4 | two: 5 | name: Account Two 6 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/fixtures/files/.keep -------------------------------------------------------------------------------- /test/fixtures/noticed/events.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | type: CommentNotifier 5 | record: one 6 | record_type: User 7 | params: 8 | foo: bar 9 | notifications_count: 1 10 | 11 | two: 12 | type: CommentNotifier 13 | record: two 14 | record_type: User 15 | params: 16 | foo: bar 17 | notifications_count: 1 18 | 19 | three: 20 | type: ReceiptNotifier 21 | record: two 22 | record_type: User 23 | params: 24 | foo: bar 25 | notifications_count: 2 26 | 27 | account: 28 | type: ReceiptNotifier 29 | record: two 30 | record_type: User 31 | params: 32 | foo: bar 33 | account: 34 | _aj_globalid: gid://dummy/Account/<%= ActiveRecord::FixtureSet.identify(:primary) %> 35 | _aj_symbol_keys: 36 | - account 37 | 38 | missing_account: 39 | type: ReceiptNotifier 40 | record: two 41 | record_type: User 42 | params: 43 | foo: bar 44 | account: 45 | _aj_globalid: gid://dummy/Account/100000 46 | _aj_symbol_keys: 47 | - account 48 | -------------------------------------------------------------------------------- /test/fixtures/noticed/notifications.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | event: one 5 | type: CommentNotifier::Notification 6 | recipient: one (User) 7 | read_at: 2023-12-15 13:03:19 8 | seen_at: 2023-12-15 13:03:19 9 | 10 | two: 11 | event: two 12 | type: CommentNotifier::Notification 13 | recipient: two (User) 14 | read_at: 2023-12-15 13:03:19 15 | seen_at: 2023-12-15 13:03:19 16 | 17 | three: 18 | event: three 19 | type: ReceiptNotifier::Notification 20 | recipient: one (Account) 21 | read_at: 2023-12-15 13:03:19 22 | seen_at: 2023-12-15 13:03:19 23 | 24 | four: 25 | event: three 26 | type: ReceiptNotifier::Notification 27 | recipient: two (Account) 28 | read_at: 2023-12-15 13:03:19 29 | seen_at: 2023-12-15 13:03:19 30 | -------------------------------------------------------------------------------- /test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | one: 2 | email: one@example.org 3 | 4 | two: 5 | email: two@example.org 6 | 7 | admin: 8 | type: Admin 9 | email: admin@example.org 10 | -------------------------------------------------------------------------------- /test/has_notifications_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class HasNotificationsTest < ActiveSupport::TestCase 4 | test "has_noticed_notifications" do 5 | assert User.respond_to?(:has_noticed_notifications) 6 | end 7 | 8 | test "noticed notifications association" do 9 | assert user.respond_to?(:notifications_as_user) 10 | end 11 | 12 | test "noticed notifications with custom name" do 13 | assert user.respond_to?(:notifications_as_owner) 14 | end 15 | 16 | test "association returns notifications" do 17 | assert_difference "user.notifications_as_user.count" do 18 | SimpleNotifier.with(user: user, message: "test").deliver(user) 19 | end 20 | end 21 | 22 | test "association with custom name returns notifications" do 23 | assert_difference "user.notifications_as_owner.count" do 24 | SimpleNotifier.with(owner: user, message: "test").deliver(user) 25 | end 26 | end 27 | 28 | test "deletes notifications with matching param" do 29 | SimpleNotifier.with(user: user, message: "test").deliver(users(:two)) 30 | 31 | assert_difference "Noticed::Event.count", -1 do 32 | user.destroy 33 | end 34 | end 35 | 36 | test "doesn't delete notifications when disabled" do 37 | SimpleNotifier.with(owner: user, message: "test").deliver(users(:two)) 38 | 39 | assert_no_difference "Noticed::Event.count" do 40 | user.destroy 41 | end 42 | end 43 | 44 | def user 45 | @user ||= users(:one) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/jobs/event_job_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class EventJobTest < ActiveJob::TestCase 4 | module ::Noticed 5 | class DeliveryMethods::Test1 < DeliveryMethod; end 6 | 7 | class DeliveryMethods::Test2 < DeliveryMethod; end 8 | 9 | class BulkDeliveryMethods::Test1 < BulkDeliveryMethod; end 10 | 11 | class BulkDeliveryMethods::Test2 < BulkDeliveryMethod; end 12 | end 13 | 14 | test "enqueues jobs for each notification and delivery method" do 15 | Noticed::EventJob.perform_now(noticed_notifications(:one).event) 16 | assert_enqueued_jobs 3 17 | end 18 | 19 | test "skips enqueueing jobs if before_enqueue raises an error" do 20 | notification = noticed_notifications(:one) 21 | event = notification.event 22 | event.class.deliver_by :test1 do |config| 23 | config.before_enqueue = -> { false } 24 | end 25 | event.class.deliver_by :test2 do |config| 26 | config.before_enqueue = -> { throw :abort } 27 | end 28 | 29 | Noticed::EventJob.perform_now(event) 30 | assert_enqueued_jobs 4 31 | 32 | event.class.delivery_methods.delete(:test1) 33 | event.class.delivery_methods.delete(:test2) 34 | end 35 | 36 | test "skips enqueueing bulk delivery job if before_enqueue raises an error" do 37 | notification = noticed_notifications(:one) 38 | event = notification.event 39 | event.class.bulk_deliver_by :test1 do |config| 40 | config.before_enqueue = -> { false } 41 | end 42 | event.class.bulk_deliver_by :test2 do |config| 43 | config.before_enqueue = -> { throw :abort } 44 | end 45 | 46 | Noticed::EventJob.perform_now(event) 47 | assert_enqueued_jobs 4 48 | 49 | event.class.bulk_delivery_methods.delete(:test1) 50 | event.class.bulk_delivery_methods.delete(:test2) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/excid3/noticed/d63ddeef0e6561e1c2eb39581f384cea321db415/test/models/.keep -------------------------------------------------------------------------------- /test/models/noticed/deliverable/deliver_by_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Noticed::Deliverable::DeliverByTest < ActiveSupport::TestCase 6 | class TestDelivery < Noticed::Deliverable::DeliverBy; end 7 | 8 | test "#perform? returns true when before_enqueue is missing" do 9 | config = ActiveSupport::OrderedOptions.new.merge({}) 10 | assert_equal true, TestDelivery.new(:test, config).perform?({}) 11 | end 12 | 13 | test "#perform? returns false when before_enqueue throws" do 14 | config = ActiveSupport::OrderedOptions.new.merge({}) 15 | config.before_enqueue = -> { throw :abort } 16 | assert_equal false, TestDelivery.new(:test, config).perform?({}) 17 | end 18 | 19 | test "#perform? returns true when before_enqueue does not throw" do 20 | config = ActiveSupport::OrderedOptions.new.merge({}) 21 | config.before_enqueue = -> { false } 22 | assert_equal true, TestDelivery.new(:test, config).perform?({}) 23 | end 24 | 25 | test "#perform? takes context into account" do 26 | config = ActiveSupport::OrderedOptions.new.merge({}) 27 | config.before_enqueue = -> { throw :abort if key?(:test_value) } 28 | assert_equal false, TestDelivery.new(:test, config).perform?({test_value: true}) 29 | assert_equal true, TestDelivery.new(:test, config).perform?({other_value: true}) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/models/noticed/event_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Noticed::EventTest < ActiveSupport::TestCase 4 | class ExampleNotifier < Noticed::Event 5 | deliver_by :test 6 | required_params :message 7 | end 8 | 9 | test "validates required params" do 10 | assert_raises Noticed::ValidationError do 11 | ExampleNotifier.deliver 12 | end 13 | end 14 | 15 | test "deliver saves event" do 16 | assert_difference "Noticed::Event.count" do 17 | ExampleNotifier.with(message: "test").deliver 18 | end 19 | end 20 | 21 | test "deliver saves notifications" do 22 | assert_no_difference "Noticed::Notification.count" do 23 | ExampleNotifier.with(message: "test").deliver 24 | end 25 | 26 | assert_difference "Noticed::Notification.count" do 27 | ExampleNotifier.with(message: "test").deliver(users(:one)) 28 | end 29 | 30 | assert_difference "Noticed::Notification.count", User.count do 31 | ExampleNotifier.with(message: "test").deliver(User.all) 32 | end 33 | end 34 | 35 | test "deliver extracts record from params" do 36 | account = accounts(:one) 37 | event = ExampleNotifier.with(message: "test", record: account).deliver 38 | assert_equal account, event.record 39 | end 40 | 41 | test "deserialize_error?" do 42 | assert noticed_events(:missing_account).deserialize_error? 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/models/noticed/notification_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Noticed::NotificationTest < ActiveSupport::TestCase 4 | test "delegates params to event" do 5 | notification = noticed_notifications(:one) 6 | assert_equal notification.event.params, notification.params 7 | end 8 | 9 | test "delegates record to event" do 10 | notification = noticed_notifications(:one) 11 | assert_equal notification.event.record, notification.record 12 | end 13 | 14 | test "notification associations" do 15 | assert_equal 1, users(:one).notifications.count 16 | end 17 | 18 | test "read scope" do 19 | assert_equal 4, Noticed::Notification.read.count 20 | end 21 | 22 | test "unread scope" do 23 | assert_equal 0, Noticed::Notification.unread.count 24 | end 25 | 26 | test "seen scope" do 27 | assert_equal 4, Noticed::Notification.seen.count 28 | end 29 | 30 | test "unseen scope" do 31 | assert_equal 0, Noticed::Notification.unseen.count 32 | end 33 | 34 | test "mark_as_read" do 35 | Noticed::Notification.update_all(read_at: nil) 36 | assert_equal 0, Noticed::Notification.read.count 37 | Noticed::Notification.mark_as_read 38 | assert_equal 4, Noticed::Notification.read.count 39 | end 40 | 41 | test "mark_as_unread" do 42 | Noticed::Notification.update_all(read_at: Time.current) 43 | assert_equal 4, Noticed::Notification.read.count 44 | Noticed::Notification.mark_as_unread 45 | assert_equal 0, Noticed::Notification.read.count 46 | end 47 | 48 | test "mark_as_seen" do 49 | Noticed::Notification.update_all(seen_at: nil) 50 | assert_equal 0, Noticed::Notification.seen.count 51 | Noticed::Notification.mark_as_seen 52 | assert_equal 4, Noticed::Notification.seen.count 53 | end 54 | 55 | test "mark_as_unseen" do 56 | Noticed::Notification.update_all(seen_at: Time.current) 57 | assert_equal 4, Noticed::Notification.seen.count 58 | Noticed::Notification.mark_as_unseen 59 | assert_equal 0, Noticed::Notification.seen.count 60 | end 61 | 62 | test "mark_as_read_and_seen" do 63 | Noticed::Notification.update_all(read_at: nil, seen_at: nil) 64 | assert_equal 0, Noticed::Notification.read.count 65 | assert_equal 0, Noticed::Notification.seen.count 66 | Noticed::Notification.mark_as_read_and_seen 67 | assert_equal 4, Noticed::Notification.read.count 68 | assert_equal 4, Noticed::Notification.seen.count 69 | end 70 | 71 | test "mark_as_unread_and_unseen" do 72 | Noticed::Notification.update_all(read_at: Time.current, seen_at: Time.current) 73 | assert_equal 4, Noticed::Notification.read.count 74 | assert_equal 4, Noticed::Notification.seen.count 75 | Noticed::Notification.mark_as_unread_and_unseen 76 | assert_equal 0, Noticed::Notification.read.count 77 | assert_equal 0, Noticed::Notification.seen.count 78 | end 79 | 80 | test "read?" do 81 | assert noticed_notifications(:one).read? 82 | end 83 | 84 | test "unread?" do 85 | assert_not noticed_notifications(:one).unread? 86 | end 87 | 88 | test "seen?" do 89 | assert noticed_notifications(:one).seen? 90 | end 91 | 92 | test "unseen?" do 93 | assert_not noticed_notifications(:one).unseen? 94 | end 95 | 96 | test "notification url helpers" do 97 | assert_equal "http://localhost:3000/", CommentNotifier::Notification.new.root_url 98 | end 99 | 100 | test "ephemeral notification url helpers" do 101 | assert_equal "http://localhost:3000/", EphemeralNotifier::Notification.new.root_url 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/noticed_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NoticedTest < ActiveSupport::TestCase 4 | test "it has a version number" do 5 | assert Noticed::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/notifier_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class NotifierTest < ActiveSupport::TestCase 4 | include ActiveJob::TestHelper 5 | 6 | class RecipientsBlock < Noticed::Event 7 | recipients do 8 | params.fetch(:recipients) 9 | end 10 | deliver_by :test 11 | end 12 | 13 | class RecipientsLambda < Noticed::Event 14 | recipients -> { params.fetch(:recipients) } 15 | deliver_by :test 16 | end 17 | 18 | class RecipientsMethod < Noticed::Event 19 | deliver_by :test 20 | recipients :recipients 21 | 22 | def recipients 23 | params.fetch(:recipients) 24 | end 25 | end 26 | 27 | class RecipientsLambdaEphemeral < Noticed::Ephemeral 28 | recipients -> { params.fetch(:recipients) } 29 | deliver_by :test 30 | end 31 | 32 | test "includes Rails urls" do 33 | assert_equal "http://localhost:3000/", SimpleNotifier.new.url 34 | end 35 | 36 | test "notifiers inherit required params" do 37 | assert_equal [:message], InheritedNotifier.required_params 38 | end 39 | 40 | test "notification_methods adds methods to Noticed::Notifications" do 41 | user = users(:one) 42 | event = SimpleNotifier.with(message: "test").deliver(user) 43 | assert_equal "hello #{user.email}", event.notifications.last.message 44 | end 45 | 46 | test "notification_methods url helpers" do 47 | assert_equal "http://localhost:3000/", SimpleNotifier::Notification.new.url 48 | end 49 | 50 | test "serializes globalid objects with text column" do 51 | user = users(:one) 52 | notification = Noticed::Event.create!(type: "SimpleNotifier", params: {user: user}) 53 | assert_equal({user: user}, notification.params) 54 | end 55 | 56 | test "assigns record association from params" do 57 | user = users(:one) 58 | notifier = RecordNotifier.with(record: user) 59 | assert_equal user, notifier.record 60 | assert_empty notifier.params 61 | end 62 | 63 | test "can add validations for record association" do 64 | notifier = RecordNotifier.with({}) 65 | refute notifier.valid? 66 | assert_equal ["can't be blank"], notifier.errors[:record] 67 | end 68 | 69 | test "recipients block" do 70 | event = RecipientsBlock.with(recipients: [User.create!(email: "foo"), User.create!(email: "bar")]).deliver 71 | assert_equal 2, event.notifications.count 72 | assert_equal User.find_by(email: "foo"), event.notifications.first.recipient 73 | end 74 | 75 | test "recipients lambda" do 76 | event = RecipientsLambda.with(recipients: [User.create!(email: "foo"), User.create!(email: "bar")]).deliver 77 | assert_equal 2, event.notifications.count 78 | assert_equal User.find_by(email: "foo"), event.notifications.first.recipient 79 | end 80 | 81 | test "recipients" do 82 | event = RecipientsMethod.with(recipients: [User.create!(email: "foo"), User.create!(email: "bar")]).deliver 83 | assert_equal 2, event.notifications.count 84 | assert_equal User.find_by(email: "foo"), event.notifications.first.recipient 85 | 86 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last]) do 87 | perform_enqueued_jobs 88 | end 89 | end 90 | 91 | test "recipients ephemeral" do 92 | users = [User.create!(email: "foo"), User.create!(email: "bar")] 93 | 94 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, "NotifierTest::RecipientsLambdaEphemeral::Notification", {recipient: User.find_by(email: "foo"), params: {recipients: users}}]) do 95 | RecipientsLambdaEphemeral.with(recipients: users).deliver 96 | end 97 | end 98 | 99 | test "deliver without recipients" do 100 | assert_nothing_raised do 101 | ReceiptNotifier.deliver 102 | end 103 | end 104 | 105 | test "deliver creates an event" do 106 | assert_difference "Noticed::Event.count" do 107 | ReceiptNotifier.deliver(User.first) 108 | end 109 | end 110 | 111 | test "deliver creates notifications for each recipient" do 112 | assert_no_difference "Noticed::Notification.count" do 113 | event = ReceiptNotifier.deliver 114 | assert_equal 0, event.notifications_count 115 | end 116 | 117 | assert_difference "Noticed::Notification.count" do 118 | event = ReceiptNotifier.deliver(User.first) 119 | assert_equal 1, event.notifications_count 120 | end 121 | 122 | assert_difference "Noticed::Notification.count", User.count do 123 | event = ReceiptNotifier.deliver(User.all) 124 | assert_equal User.count, event.notifications_count 125 | end 126 | 127 | assert_difference "Noticed::Notification.count", -1 do 128 | event = noticed_events(:one) 129 | event.notifications.destroy_all 130 | assert_equal 0, event.notifications_count 131 | end 132 | end 133 | 134 | test "deliver to STI recipient writes base class" do 135 | admin = Admin.first 136 | assert_difference "Noticed::Notification.count" do 137 | ReceiptNotifier.deliver(admin) 138 | end 139 | notification = Noticed::Notification.last 140 | assert_equal "User", notification.recipient_type 141 | assert_equal admin, notification.recipient 142 | end 143 | 144 | test "creates jobs for deliveries" do 145 | # Delivering a notification creates records 146 | assert_enqueued_jobs 1, only: Noticed::EventJob do 147 | ReceiptNotifier.deliver(User.first) 148 | end 149 | 150 | # Run the Event Job 151 | assert_enqueued_jobs 1, only: Noticed::DeliveryMethods::Test do 152 | perform_enqueued_jobs 153 | end 154 | 155 | # Run the individual deliveries 156 | perform_enqueued_jobs 157 | 158 | assert_equal Noticed::Notification.last, Noticed::DeliveryMethods::Test.delivered.last 159 | end 160 | 161 | test "creates jobs for bulk deliveries" do 162 | assert_enqueued_jobs 1, only: Noticed::EventJob do 163 | BulkNotifier.deliver 164 | end 165 | 166 | assert_enqueued_jobs 1, only: Noticed::BulkDeliveryMethods::Webhook do 167 | perform_enqueued_jobs 168 | end 169 | end 170 | 171 | test "creates jobs for bulk ephemeral deliveries" do 172 | assert_enqueued_jobs 1, only: Noticed::BulkDeliveryMethods::Test do 173 | EphemeralNotifier.deliver 174 | end 175 | 176 | assert_difference("Noticed::BulkDeliveryMethods::Test.delivered.length" => 1) do 177 | perform_enqueued_jobs 178 | end 179 | end 180 | 181 | test "deliver wait" do 182 | freeze_time 183 | assert_enqueued_with job: Noticed::EventJob, at: 5.minutes.from_now do 184 | ReceiptNotifier.deliver(User.first, wait: 5.minutes) 185 | end 186 | end 187 | 188 | test "deliver queue" do 189 | freeze_time 190 | assert_enqueued_with job: Noticed::EventJob, queue: "low_priority" do 191 | ReceiptNotifier.deliver(User.first, queue: :low_priority) 192 | end 193 | end 194 | 195 | test "wait delivery method option" do 196 | freeze_time 197 | event = WaitNotifier.deliver(User.first) 198 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last], at: 5.minutes.from_now) do 199 | perform_enqueued_jobs 200 | end 201 | end 202 | 203 | test "wait_until delivery method option" do 204 | freeze_time 205 | event = WaitUntilNotifier.deliver(User.first) 206 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last], at: 1.hour.from_now) do 207 | perform_enqueued_jobs 208 | end 209 | end 210 | 211 | test "queue delivery method option" do 212 | event = QueueNotifier.deliver(User.first) 213 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last], queue: "example_queue") do 214 | perform_enqueued_jobs 215 | end 216 | end 217 | 218 | # assert_enqeued_with doesn't support priority before Rails 7 219 | if Rails.gem_version >= Gem::Version.new("7.0.0.alpha1") 220 | test "priority delivery method option" do 221 | event = PriorityNotifier.deliver(User.first) 222 | assert_enqueued_with(job: Noticed::DeliveryMethods::Test, args: [:test, event.notifications.last], priority: 2) do 223 | perform_enqueued_jobs 224 | end 225 | end 226 | end 227 | 228 | test "deprecations don't cause problems" do 229 | assert_nothing_raised do 230 | Noticed.deprecator.silence do 231 | DeprecatedNotifier.with(message: "test").deliver_later 232 | end 233 | end 234 | end 235 | 236 | test "inherits notification_methods from application notifier" do 237 | assert SimpleNotifier::Notification.new.respond_to?(:inherited_method) 238 | end 239 | end 240 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require_relative "../test/dummy/config/environment" 5 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 6 | require "rails/test_help" 7 | require "minitest/mock" 8 | require "webmock/minitest" 9 | 10 | # Load fixtures from the engine 11 | if ActiveSupport::TestCase.respond_to?(:fixture_paths=) 12 | ActiveSupport::TestCase.fixture_paths = [File.expand_path("fixtures", __dir__)] 13 | ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths 14 | ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" 15 | ActiveSupport::TestCase.fixtures :all 16 | elsif ActiveSupport::TestCase.respond_to?(:fixture_path=) 17 | ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) 18 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 19 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" 20 | ActiveSupport::TestCase.fixtures :all 21 | end 22 | -------------------------------------------------------------------------------- /test/translation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TranslationTest < ActiveSupport::TestCase 4 | class I18nExample < Noticed::Event 5 | deliver_by :test do |config| 6 | config.message = -> { t("hello") } 7 | end 8 | 9 | def message 10 | t("hello") 11 | end 12 | 13 | def html_message 14 | t("message_html") 15 | end 16 | end 17 | 18 | class Noticed::I18nExample < Noticed::Event 19 | def message 20 | t(".message") 21 | end 22 | end 23 | 24 | class ::ScopedI18nExample < Noticed::Event 25 | def i18n_scope 26 | :noticed 27 | end 28 | 29 | def message 30 | t(".message") 31 | end 32 | end 33 | 34 | test "I18n support" do 35 | assert_equal "hello", I18nExample.new.send(:scope_translation_key, "hello") 36 | assert_equal "Hello world", I18nExample.new.message 37 | end 38 | 39 | test "I18n supports namespaces" do 40 | assert_equal "notifiers.noticed.i18n_example.message", Noticed::I18nExample.new.send(:scope_translation_key, ".message") 41 | assert_equal "This is a notification", Noticed::I18nExample.new.message 42 | end 43 | 44 | test "I18n supports custom scopes" do 45 | assert_equal "noticed.scoped_i18n_example.message", ScopedI18nExample.new.send(:scope_translation_key, ".message") 46 | assert_equal "This is a custom scoped translation", ScopedI18nExample.new.message 47 | end 48 | 49 | if defined?(ActiveSupport::HtmlSafeTranslation) 50 | test "I18n supports html safe translations" do 51 | message = I18nExample.new.html_message 52 | assert_equal "

Hello world

", message 53 | assert message.html_safe? 54 | end 55 | end 56 | 57 | test "delivery method blocks can use translations" do 58 | block = I18nExample.delivery_methods[:test].config[:message] 59 | assert_equal "Hello world", noticed_notifications(:one).instance_exec(&block) 60 | end 61 | 62 | test "ephemeral translations" do 63 | assert_equal "Hello world", EphemeralNotifier.new.t("hello") 64 | end 65 | 66 | test "ephemeral notification translations" do 67 | assert_equal "Hello world", EphemeralNotifier::Notification.new.t("hello") 68 | end 69 | end 70 | --------------------------------------------------------------------------------