Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,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'

Expand Down
10 changes: 10 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -837,13 +845,15 @@ DEPENDENCIES
scout_apm
searchkick
selenium-webdriver
serviceworker-rails
sidekiq
sprockets (< 4)
terser
timecop
unicorn
validate_url
vcr
web-push
webrat
will_paginate
will_paginate-bootstrap-style
Expand Down
1 change: 1 addition & 0 deletions app/assets/config/manifest.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// = link_tree ../images
// = link serviceworker.js
// = link_directory ../javascripts .js
// = link_directory ../stylesheets .css
59 changes: 59 additions & 0 deletions app/assets/javascripts/push_notifications.js
Original file line number Diff line number Diff line change
@@ -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() })
});
});
});
}
});
}
});
13 changes: 13 additions & 0 deletions app/assets/javascripts/serviceworker.js.erb
Original file line number Diff line number Diff line change
@@ -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)
);
});
20 changes: 20 additions & 0 deletions app/controllers/push_subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions app/jobs/push_notification_job.rb
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions app/models/push_subscription.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class PushSubscription < ApplicationRecord
belongs_to :member
end
31 changes: 31 additions & 0 deletions app/services/push_notification_service.rb
Original file line number Diff line number Diff line change
@@ -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', '[email protected]')}",
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
2 changes: 2 additions & 0 deletions app/views/layouts/_head.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions app/views/members/_notifications.html.haml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/views/members/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20240929041436_add_timezone_to_members.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddTimezoneToMembers < ActiveRecord::Migration[7.2]
def change
add_column :members, :timezone, :string
end
end
15 changes: 15 additions & 0 deletions db/migrate/20240929041437_create_push_subscriptions.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@
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
Expand Down Expand Up @@ -576,6 +577,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.integer "overall_rating"
t.index ["crop_id"], name: "index_plantings_on_crop_id"
t.index ["garden_id"], name: "index_plantings_on_garden_id"
Expand All @@ -599,6 +601,43 @@
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"
Expand Down Expand Up @@ -658,5 +697,10 @@
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
6 changes: 6 additions & 0 deletions env-example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading