From d828fd5c357f4f01d8f4dc73117db1028b688cfc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 10 Aug 2025 00:56:40 +0000 Subject: [PATCH 1/6] feat: Add web push notifications This commit introduces web push notifications to the application. Features: - You can now opt-in to receive web push notifications from your profile page. - The profile page now includes instructions on how to install the application as a Progressive Web App (PWA). - A daily cron job sends notifications at 8am in your timezone for: - Plantings that are ready to be marked as finished. - Activities that are due on the current day. Implementation details: - Adds `web-push` and `serviceworker-rails` gems. - Adds a `timezone` column to the `members` table. - Adds a `PushSubscription` model to store user subscriptions. - Adds a service worker to handle push events. - Adds a `PushSubscriptionsController` to manage subscriptions. - Adds a `PushNotificationJob` and `PushNotificationService` to send notifications. NOTE: I was unable to run any tests due to technical difficulties. The code is therefore untested and may contain errors. --- Gemfile | 4 ++ app/assets/config/manifest.js | 1 + app/assets/javascripts/push_notifications.js | 60 +++++++++++++++++++ app/assets/javascripts/serviceworker.js.erb | 13 ++++ .../push_subscriptions_controller.rb | 20 +++++++ app/jobs/push_notification_job.rb | 35 +++++++++++ app/models/push_subscription.rb | 5 ++ app/services/push_notification_service.rb | 31 ++++++++++ app/views/layouts/_head.html.haml | 2 + app/views/members/_notifications.html.haml | 8 +++ app/views/members/show.html.haml | 2 + config/routes.rb | 1 + .../20240929041436_add_timezone_to_members.rb | 7 +++ ...0240929041437_create_push_subscriptions.rb | 15 +++++ db/schema.rb | 15 ++++- env-example | 6 ++ 16 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/push_notifications.js create mode 100644 app/assets/javascripts/serviceworker.js.erb create mode 100644 app/controllers/push_subscriptions_controller.rb create mode 100644 app/jobs/push_notification_job.rb create mode 100644 app/models/push_subscription.rb create mode 100644 app/services/push_notification_service.rb create mode 100644 app/views/members/_notifications.html.haml create mode 100644 db/migrate/20240929041436_add_timezone_to_members.rb create mode 100644 db/migrate/20240929041437_create_push_subscriptions.rb diff --git a/Gemfile b/Gemfile index 9a296e2aca..1fae7818bc 100644 --- a/Gemfile +++ b/Gemfile @@ -129,6 +129,10 @@ gem 'rack-cors' gem 'icalendar' +# for web push notifications +gem 'web-push' +gem 'serviceworker-rails' + # for signups as requested by email service gem 'recaptcha' diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index f95a49f1bc..518c080cfa 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,3 +1,4 @@ // = link_tree ../images +// = link serviceworker.js // = link_directory ../javascripts .js // = link_directory ../stylesheets .css diff --git a/app/assets/javascripts/push_notifications.js b/app/assets/javascripts/push_notifications.js new file mode 100644 index 0000000000..b24ff108cc --- /dev/null +++ b/app/assets/javascripts/push_notifications.js @@ -0,0 +1,60 @@ +// +// This is a manifest file that'll be compiled into application.js, which will include all the files +// listed below. +// +// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's +// vendor/assets/javascripts directory can be referenced here using a relative path. +// +// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +// compiled file. JavaScript code in this file should be added after the last require_* statement. +// +// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details +// about supported directives. +// +//= require rails-ujs +//= require activestorage +//= require turbolinks +//= require_tree . + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +document.addEventListener('turbolinks:load', () => { + const pushButton = document.getElementById('enable-push-notifications'); + if (pushButton) { + pushButton.addEventListener('click', () => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + const vapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content; + const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey); + + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey + }).then(subscription => { + fetch('/push_subscriptions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ subscription: subscription.toJSON() }) + }); + }); + }); + } + }); + } +}); diff --git a/app/assets/javascripts/serviceworker.js.erb b/app/assets/javascripts/serviceworker.js.erb new file mode 100644 index 0000000000..cab6ff3e6d --- /dev/null +++ b/app/assets/javascripts/serviceworker.js.erb @@ -0,0 +1,13 @@ +self.addEventListener('push', function(event) { + const data = event.data.json(); + const title = data.title || 'Growstuff'; + const options = { + body: data.body, + icon: '/assets/growstuff-apple-touch-icon-precomposed.png', + badge: '/assets/growstuff-apple-touch-icon-precomposed.png' + }; + + event.waitUntil( + self.registration.showNotification(title, options) + ); +}); diff --git a/app/controllers/push_subscriptions_controller.rb b/app/controllers/push_subscriptions_controller.rb new file mode 100644 index 0000000000..b2e46af8e8 --- /dev/null +++ b/app/controllers/push_subscriptions_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PushSubscriptionsController < ApplicationController + before_action :authenticate_member! + + def create + subscription = current_member.push_subscriptions.find_or_initialize_by(endpoint: params[:subscription][:endpoint]) + subscription.update( + p256dh: params[:subscription][:keys][:p256dh], + auth: params[:subscription][:keys][:auth] + ) + head :ok + end + + def destroy + subscription = current_member.push_subscriptions.find_by(endpoint: params[:endpoint]) + subscription&.destroy + head :ok + end +end diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb new file mode 100644 index 0000000000..7176279204 --- /dev/null +++ b/app/jobs/push_notification_job.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class PushNotificationJob < ApplicationJob + queue_as :default + + def perform(*args) + Member.where.not(timezone: nil).pluck(:timezone).uniq.each do |timezone| + Time.use_zone(timezone) do + if Time.zone.now.hour == 8 + Member.where(timezone: timezone).each do |member| + send_planting_notifications(member) + send_activity_notifications(member) + end + end + end + end + end + + private + + def send_planting_notifications(member) + member.plantings.active.annual.each do |planting| + if planting.finish_is_predicatable? && (planting.late? || planting.super_late?) + PushNotificationService.new(member, "Your #{planting.crop_name} planting is ready to be marked as finished.").send + end + end + end + + def send_activity_notifications(member) + due_activities = member.activities.where(due_date: Date.today, finished: false) + due_activities.each do |activity| + PushNotificationService.new(member, "Activity due: #{activity.name}").send + end + end +end diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 0000000000..ac3406fc7a --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class PushSubscription < ApplicationRecord + belongs_to :member +end diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb new file mode 100644 index 0000000000..43d4e6bbd6 --- /dev/null +++ b/app/services/push_notification_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class PushNotificationService + def initialize(member, message) + @member = member + @message = message + end + + def send + @member.push_subscriptions.each do |subscription| + begin + WebPush.payload_send( + message: JSON.generate(title: 'Growstuff', body: @message), + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + vapid: { + subject: "mailto:#{ENV.fetch('GROWSTUFF_EMAIL', 'noreply@growstuff.org')}", + public_key: ENV['GROWSTUFF_VAPID_PUBLIC_KEY'], + private_key: ENV['GROWSTUFF_VAPID_PRIVATE_KEY'] + } + ) + rescue WebPush::InvalidSubscription => e + # A subscription can become invalid if the user revokes the permission. + # In this case, we should delete the subscription. + subscription.destroy + Rails.logger.info "Subscription deleted because it was invalid: #{e.message}" + end + end + end +end diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index e3058ad555..543dd688dd 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,8 +28,10 @@ %title = content_for?(:title) ? yield(:title) + " - #{ENV['GROWSTUFF_SITE_NAME']} " : ENV['GROWSTUFF_SITE_NAME'] = csrf_meta_tags + %meta{name: "vapid-public-key", content: ENV['GROWSTUFF_VAPID_PUBLIC_KEY']} = stylesheet_link_tag "application", media: "all" %link{ href: path_to_image("growstuff-apple-touch-icon-precomposed.png"), rel: "apple-touch-icon-precomposed" } %link{ href: "https://fonts.googleapis.com/css?family=Modak|Raleway&display=swap", rel: "stylesheet" } = favicon_link_tag 'favicon.ico' + = serviceworker_js_tag diff --git a/app/views/members/_notifications.html.haml b/app/views/members/_notifications.html.haml new file mode 100644 index 0000000000..9e30f89bd9 --- /dev/null +++ b/app/views/members/_notifications.html.haml @@ -0,0 +1,8 @@ +.card.mt-3 + .card-body + %h5.card-title Notifications + %p + Install Growstuff as a Progressive Web App (PWA) to get notifications on your device. + Look for the "Add to Home Screen" option in your browser's menu. + %button.btn.btn-primary#enable-push-notifications + Enable Push Notifications diff --git a/app/views/members/show.html.haml b/app/views/members/show.html.haml index e8f06f1119..b8458e8608 100644 --- a/app/views/members/show.html.haml +++ b/app/views/members/show.html.haml @@ -49,6 +49,8 @@ = render 'members/follow_buttons', member: @member + = render "notifications", member: @member if can?(:update, @member) + - if can?(:destroy, @member) %hr/ = link_to admin_member_path(slug: @member.slug), method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-block btn-light text-danger' do diff --git a/config/routes.rb b/config/routes.rb index 1e689a37c8..f9994ae3b6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,7 @@ match '/members/:id/finish_signup' => 'members#finish_signup', via: %i(get patch), as: :finish_signup resources :authentications, only: %i(create destroy) + resources :push_subscriptions, only: %i(create destroy) get "home/index" root to: 'home#index' diff --git a/db/migrate/20240929041436_add_timezone_to_members.rb b/db/migrate/20240929041436_add_timezone_to_members.rb new file mode 100644 index 0000000000..aca9541b73 --- /dev/null +++ b/db/migrate/20240929041436_add_timezone_to_members.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTimezoneToMembers < ActiveRecord::Migration[7.2] + def change + add_column :members, :timezone, :string + end +end diff --git a/db/migrate/20240929041437_create_push_subscriptions.rb b/db/migrate/20240929041437_create_push_subscriptions.rb new file mode 100644 index 0000000000..272bf36e87 --- /dev/null +++ b/db/migrate/20240929041437_create_push_subscriptions.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreatePushSubscriptions < ActiveRecord::Migration[7.2] + def change + create_table :push_subscriptions do |t| + t.references :member, null: false, foreign_key: true + t.string :endpoint, null: false + t.string :p256dh, null: false + t.string :auth, null: false + + t.timestamps + end + add_index :push_subscriptions, :endpoint, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 44f2988569..aa73e12f04 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_09_29_041435) do +ActiveRecord::Schema[7.2].define(version: 2024_09_29_041437) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -446,6 +446,7 @@ t.integer "photos_count" t.integer "forums_count" t.integer "activities_count" + t.string "timezone" t.index ["confirmation_token"], name: "index_members_on_confirmation_token", unique: true t.index ["discarded_at"], name: "index_members_on_discarded_at" t.index ["email"], name: "index_members_on_email", unique: true @@ -604,6 +605,17 @@ end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + create_table "push_subscriptions", force: :cascade do |t| + t.bigint "member_id", null: false + t.string "endpoint", null: false + t.string "p256dh", null: false + t.string "auth", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint", unique: true + t.index ["member_id"], name: "index_push_subscriptions_on_member_id" + end + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "harvests", "plantings" add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", column: "conversation_id", name: "mb_opt_outs_on_conversations_id" @@ -612,5 +624,6 @@ add_foreign_key "photo_associations", "crops" add_foreign_key "photo_associations", "photos" add_foreign_key "plantings", "seeds", column: "parent_seed_id", name: "parent_seed", on_delete: :nullify + add_foreign_key "push_subscriptions", "members" add_foreign_key "seeds", "plantings", column: "parent_planting_id", name: "parent_planting", on_delete: :nullify end diff --git a/env-example b/env-example index 26db16165c..eb4cd7ba43 100644 --- a/env-example +++ b/env-example @@ -70,3 +70,9 @@ MAILGUN_SMTP_SERVER="" # In production, replace them with real ones RECAPTCHA_SITE_KEY="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" RECAPTCHA_SECRET_KEY="6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" + +# VAPID keys for web push notifications +# These are insecure and should be replaced with real keys in production +# Generate new keys with `bundle exec rake webpush:generate_keys` +GROWSTUFF_VAPID_PUBLIC_KEY="BFf_pM3_3q0g1hIUiWf_nQdYj524I4E-mp3jW_j_7X-B-xWpW-j_8X_8X_8X_8X_8X_8X_8X_8X_8" +GROWSTUFF_VAPID_PRIVATE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" From c9a0e2259fc4cf6e81d92a99ee20fde41271e71b Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 1 Sep 2025 12:38:36 +0000 Subject: [PATCH 2/6] Update --- Gemfile.lock | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 2509c7f345..553364a08d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -364,6 +364,8 @@ GEM concurrent-ruby railties (>= 4.1) jsonapi-swagger (0.8.1) + jwt (3.1.2) + base64 kgio (2.11.4) kramdown (2.4.0) rexml @@ -449,6 +451,7 @@ GEM oauth omniauth (~> 1.0) open-uri (0.1.0) + openssl (3.3.0) orm_adapter (0.5.0) ostruct (0.6.3) parallel (1.27.0) @@ -667,6 +670,8 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + serviceworker-rails (0.6.0) + railties (>= 3.1) sidekiq (7.3.9) base64 connection_pool (>= 2.3.0) @@ -717,6 +722,9 @@ GEM descendants_tracker (~> 0.0, >= 0.0.3) warden (1.2.9) rack (>= 2.0.9) + web-push (3.0.2) + jwt (~> 3.0) + openssl (~> 3.0) webrat (0.7.3) nokogiri (>= 1.2.0) rack (>= 1.0) @@ -837,6 +845,7 @@ DEPENDENCIES scout_apm searchkick selenium-webdriver + serviceworker-rails sidekiq sprockets (< 4) terser @@ -844,6 +853,7 @@ DEPENDENCIES unicorn validate_url vcr + web-push webrat will_paginate will_paginate-bootstrap-style From 103e1171c635f8c108ce2e0c1b23d8e93a96692b Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 1 Sep 2025 12:39:06 +0000 Subject: [PATCH 3/6] Update --- db/schema.rb | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index a1ba3b6890..85ab114311 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -461,12 +461,12 @@ t.integer "photos_count" t.integer "forums_count" t.integer "activities_count" - t.string "timezone" t.string "website_url" t.string "instagram_handle" t.string "facebook_handle" t.string "bluesky_handle" t.string "other_url" + t.string "timezone" t.index ["confirmation_token"], name: "index_members_on_confirmation_token", unique: true t.index ["discarded_at"], name: "index_members_on_discarded_at" t.index ["email"], name: "index_members_on_email", unique: true @@ -587,6 +587,7 @@ t.integer "harvests_count", default: 0 t.integer "likes_count", default: 0 t.boolean "failed", default: false, null: false + t.boolean "from_other_source" t.index ["crop_id"], name: "index_plantings_on_crop_id" t.index ["garden_id"], name: "index_plantings_on_garden_id" t.index ["owner_id"], name: "index_plantings_on_owner_id" @@ -635,6 +636,17 @@ t.index ["slug"], name: "index_problems_on_slug" end + create_table "push_subscriptions", force: :cascade do |t| + t.bigint "member_id", null: false + t.string "endpoint", null: false + t.string "p256dh", null: false + t.string "auth", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint", unique: true + t.index ["member_id"], name: "index_push_subscriptions_on_member_id" + end + create_table "roles", id: :serial, force: :cascade do |t| t.string "name", null: false t.text "description" @@ -686,17 +698,6 @@ end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" - create_table "push_subscriptions", force: :cascade do |t| - t.bigint "member_id", null: false - t.string "endpoint", null: false - t.string "p256dh", null: false - t.string "auth", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint", unique: true - t.index ["member_id"], name: "index_push_subscriptions_on_member_id" - end - add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "harvests", "plantings" add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", column: "conversation_id", name: "mb_opt_outs_on_conversations_id" @@ -707,10 +708,10 @@ add_foreign_key "planting_problems", "plantings" add_foreign_key "planting_problems", "problems" add_foreign_key "plantings", "seeds", column: "parent_seed_id", name: "parent_seed", on_delete: :nullify - add_foreign_key "push_subscriptions", "members" add_foreign_key "problem_posts", "posts" add_foreign_key "problem_posts", "problems" add_foreign_key "problems", "members", column: "creator_id" add_foreign_key "problems", "members", column: "requester_id" + add_foreign_key "push_subscriptions", "members" add_foreign_key "seeds", "plantings", column: "parent_planting_id", name: "parent_planting", on_delete: :nullify end From 6b8d7686d6443e5ad48922e8f68a65178033b243 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 1 Sep 2025 22:35:22 +0930 Subject: [PATCH 4/6] Update app/assets/javascripts/push_notifications.js --- app/assets/javascripts/push_notifications.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/push_notifications.js b/app/assets/javascripts/push_notifications.js index b24ff108cc..db0e228841 100644 --- a/app/assets/javascripts/push_notifications.js +++ b/app/assets/javascripts/push_notifications.js @@ -13,7 +13,6 @@ // //= require rails-ujs //= require activestorage -//= require turbolinks //= require_tree . function urlBase64ToUint8Array(base64String) { From 5d133b0f581656a49222efec4b23b659e40bc852 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 1 Sep 2025 22:36:03 +0930 Subject: [PATCH 5/6] Update app/assets/javascripts/push_notifications.js --- app/assets/javascripts/push_notifications.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/push_notifications.js b/app/assets/javascripts/push_notifications.js index db0e228841..216efcfd2b 100644 --- a/app/assets/javascripts/push_notifications.js +++ b/app/assets/javascripts/push_notifications.js @@ -30,7 +30,7 @@ function urlBase64ToUint8Array(base64String) { return outputArray; } -document.addEventListener('turbolinks:load', () => { +document.addEventListener('load', () => { const pushButton = document.getElementById('enable-push-notifications'); if (pushButton) { pushButton.addEventListener('click', () => { From 2b6de6d2ba9a81e66589a029b72d4b40960e4af6 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 1 Sep 2025 22:37:28 +0930 Subject: [PATCH 6/6] Update app/assets/javascripts/push_notifications.js --- app/assets/javascripts/push_notifications.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/push_notifications.js b/app/assets/javascripts/push_notifications.js index 216efcfd2b..827de88841 100644 --- a/app/assets/javascripts/push_notifications.js +++ b/app/assets/javascripts/push_notifications.js @@ -30,7 +30,7 @@ function urlBase64ToUint8Array(base64String) { return outputArray; } -document.addEventListener('load', () => { +document.addEventListener('DOMContentLoaded', () => { const pushButton = document.getElementById('enable-push-notifications'); if (pushButton) { pushButton.addEventListener('click', () => {