Compare commits

...

12 Commits

Author SHA1 Message Date
Daniel O'Connor
2930362278 Merge branch 'dev' into feature/web-push-notifications 2026-05-03 12:58:07 +09:30
Daniel O'Connor
67793e7d8d Merge branch 'dev' into feature/web-push-notifications 2026-04-24 00:05:06 +09:30
Daniel O'Connor
7b677d6b2c Merge branch 'dev' into feature/web-push-notifications 2025-09-02 07:43:47 +09:30
Daniel O'Connor
2b6de6d2ba Update app/assets/javascripts/push_notifications.js 2025-09-01 22:37:28 +09:30
Daniel O'Connor
5d133b0f58 Update app/assets/javascripts/push_notifications.js 2025-09-01 22:36:03 +09:30
Daniel O'Connor
6b8d7686d6 Update app/assets/javascripts/push_notifications.js 2025-09-01 22:35:22 +09:30
Daniel O'Connor
103e1171c6 Update 2025-09-01 12:39:06 +00:00
Daniel O'Connor
c9a0e2259f Update 2025-09-01 12:38:36 +00:00
Daniel O'Connor
51b8c2bfe9 Merge branch 'dev' of https://github.com/Growstuff/growstuff into feature/web-push-notifications 2025-09-01 12:38:06 +00:00
Daniel O'Connor
e599b9872a Merge branch 'dev' into feature/web-push-notifications 2025-08-31 15:09:15 +09:30
Daniel O'Connor
4883d6b0e0 Merge branch 'dev' into feature/web-push-notifications 2025-08-10 13:47:56 +09:30
google-labs-jules[bot]
d828fd5c35 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.
2025-08-10 00:56:40 +00:00
17 changed files with 263 additions and 0 deletions

View File

@@ -130,6 +130,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'

View File

@@ -385,6 +385,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
@@ -472,6 +474,7 @@ GEM
oauth
omniauth (~> 1.0)
open-uri (0.1.0)
openssl (3.3.0)
orm_adapter (0.5.0)
ostruct (0.6.3)
paper_trail (17.0.0)
@@ -696,6 +699,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.10)
base64
connection_pool (>= 2.3.0, < 3)
@@ -748,6 +753,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)
@@ -872,6 +880,7 @@ DEPENDENCIES
scout_apm
searchkick
selenium-webdriver
serviceworker-rails
sidekiq
sitemap_generator
sprockets (< 4)
@@ -880,6 +889,7 @@ DEPENDENCIES
unicorn
validate_url
vcr
web-push
webrat
will_paginate
will_paginate-bootstrap-style

View File

@@ -1,3 +1,4 @@
// = link_tree ../images
// = link serviceworker.js
// = link_directory ../javascripts .js
// = link_directory ../stylesheets .css

View File

@@ -0,0 +1,59 @@
//
// 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_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('DOMContentLoaded', () => {
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() })
});
});
});
}
});
}
});

View File

@@ -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)
);
});

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
class PushSubscription < ApplicationRecord
belongs_to :member
end

View File

@@ -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

View File

@@ -32,9 +32,11 @@
- else
%meta{name: "description", content: "Growstuff is a community of food gardeners. Let's learn to grow food together. All our data is open data."}
= 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
= tag("meta", name: "google-site-verification", content: "j249rPGdBqZ7gcShcdsSXCnGN5lqCuTISJnlQXxOfu4")

View File

@@ -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

View File

@@ -73,6 +73,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

View File

@@ -21,6 +21,7 @@ Rails.application.routes.draw do
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"
get '/community-gardens', to: 'home#community_gardens'

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTimezoneToMembers < ActiveRecord::Migration[7.2]
def change
add_column :members, :timezone, :string
end
end

View File

@@ -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

View File

@@ -786,6 +786,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_29_132911) do
t.string "facebook_handle"
t.string "bluesky_handle"
t.string "other_url"
t.string "timezone"
t.boolean "send_harvest_reminder", default: true, null: false
t.index ["confirmation_token"], name: "index_members_on_confirmation_token", unique: true
t.index ["discarded_at"], name: "index_members_on_discarded_at"
@@ -897,6 +898,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_29_132911) do
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.integer "overall_rating"
t.index ["crop_id"], name: "index_plantings_on_crop_id"
t.index ["garden_id"], name: "index_plantings_on_garden_id"
@@ -920,6 +922,43 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_29_132911) do
t.index ["slug"], name: "index_posts_on_slug", unique: true
end
create_table "problem_posts", force: :cascade do |t|
t.bigint "problem_id"
t.bigint "post_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id"], name: "index_problem_posts_on_post_id"
t.index ["problem_id", "post_id"], name: "index_problem_posts_on_problem_id_and_post_id", unique: true
t.index ["problem_id"], name: "index_problem_posts_on_problem_id"
end
create_table "problems", force: :cascade do |t|
t.string "name"
t.string "reason_for_rejection"
t.string "rejection_notes"
t.string "approval_status", default: "pending", null: false
t.bigint "requester_id"
t.bigint "creator_id"
t.string "slug"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["creator_id"], name: "index_problems_on_creator_id"
t.index ["name"], name: "index_problems_on_name"
t.index ["requester_id"], name: "index_problems_on_requester_id"
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"
@@ -992,5 +1031,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_29_132911) do
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 "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

View File

@@ -65,3 +65,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"