mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-05-25 17:31:18 -04:00
Compare commits
12 Commits
dev
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2930362278 | ||
|
|
67793e7d8d | ||
|
|
7b677d6b2c | ||
|
|
2b6de6d2ba | ||
|
|
5d133b0f58 | ||
|
|
6b8d7686d6 | ||
|
|
103e1171c6 | ||
|
|
c9a0e2259f | ||
|
|
51b8c2bfe9 | ||
|
|
e599b9872a | ||
|
|
4883d6b0e0 | ||
|
|
d828fd5c35 |
4
Gemfile
4
Gemfile
@@ -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'
|
||||
|
||||
|
||||
10
Gemfile.lock
10
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// = link_tree ../images
|
||||
// = link serviceworker.js
|
||||
// = link_directory ../javascripts .js
|
||||
// = link_directory ../stylesheets .css
|
||||
|
||||
59
app/assets/javascripts/push_notifications.js
Normal file
59
app/assets/javascripts/push_notifications.js
Normal 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() })
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
13
app/assets/javascripts/serviceworker.js.erb
Normal file
13
app/assets/javascripts/serviceworker.js.erb
Normal 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)
|
||||
);
|
||||
});
|
||||
20
app/controllers/push_subscriptions_controller.rb
Normal file
20
app/controllers/push_subscriptions_controller.rb
Normal 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
|
||||
35
app/jobs/push_notification_job.rb
Normal file
35
app/jobs/push_notification_job.rb
Normal 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
|
||||
5
app/models/push_subscription.rb
Normal file
5
app/models/push_subscription.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PushSubscription < ApplicationRecord
|
||||
belongs_to :member
|
||||
end
|
||||
31
app/services/push_notification_service.rb
Normal file
31
app/services/push_notification_service.rb
Normal 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
|
||||
@@ -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")
|
||||
|
||||
8
app/views/members/_notifications.html.haml
Normal file
8
app/views/members/_notifications.html.haml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
7
db/migrate/20240929041436_add_timezone_to_members.rb
Normal file
7
db/migrate/20240929041436_add_timezone_to_members.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddTimezoneToMembers < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :members, :timezone, :string
|
||||
end
|
||||
end
|
||||
15
db/migrate/20240929041437_create_push_subscriptions.rb
Normal file
15
db/migrate/20240929041437_create_push_subscriptions.rb
Normal 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
|
||||
44
db/schema.rb
44
db/schema.rb
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user