bible270 0.17.1 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70091a35da0211de345d0a946f8fe25aea94c7656d7f9fb7b305aa63c6a1b7e6
4
- data.tar.gz: 98e6db18fb16bdb784b7b2ad095b05860a43a81cb3111279f33b7285345bcbd0
3
+ metadata.gz: 9e3c27f2336f3e2a809205242bbc42c46d241e49b9ea2b1bf49ca288523d7c88
4
+ data.tar.gz: 8e8c25b59eafde481a17546ac14b563527dd591ca64d9cf7541d95a01dd1f36c
5
5
  SHA512:
6
- metadata.gz: e03f11e9596c52e59413478a3c5c615ce5bc9f4289ecbed3a037906d51d70e515d56767d18324ba331dded40de502d0d6b7c741c603836d7e351d147375187df
7
- data.tar.gz: f2496c1342b3be1625927fdbbecd333c1792d96c8c52742359bdfc9cb66e9de7ac0f3617b20b291ee081811e4d937a92e3dbbecc9c5ee29a3c3d0788db9f473a
6
+ metadata.gz: 06af092550c30ded960e3611301d941e10fdea25e79fcfcab8aa97c4d68fee7b3780961f22bc4f81be4ba3f065ebe57642386ba318c69f80e2f1bd1e07ce0dec
7
+ data.tar.gz: ed69c75af0e88e8dfeb347b756c6c10692f46b6e90196e96851aecc026a664752ce6f2fbb3de3b324734eff316555c229d19aa15270d26061b370b4db643f3ae
data/Rakefile CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'bundler/gem_tasks' # provides build / install / release
4
4
  require 'rake/testtask'
5
+ require 'coveralls/rake/task'
5
6
 
6
7
  Rake::TestTask.new(:test) do |t|
7
8
  t.libs << 'test' << 'lib'
@@ -9,4 +10,6 @@ Rake::TestTask.new(:test) do |t|
9
10
  t.warning = false
10
11
  end
11
12
 
13
+ Coveralls::RakeTask.new
14
+
12
15
  task default: :test
@@ -8,6 +8,9 @@ module Bible270
8
8
  # neither set the panel is unreachable, and every action 404s rather than 403s
9
9
  # so its existence isn't advertised.
10
10
  class AdminController < ApplicationController
11
+ LAST_BROADCAST_AT = 'last_broadcast_at'
12
+ LAST_BROADCAST_SUBJECT = 'last_broadcast_subject'
13
+
11
14
  before_action :require_admin!
12
15
  before_action :load_reader,
13
16
  only: %i[show destroy update_start update_profile remove_avatar complete_through toggle_day]
@@ -32,6 +35,9 @@ module Bible270
32
35
  counts = Checkoff.group(:reader_id, :day).count
33
36
  @days_completed = Hash.new(0)
34
37
  counts.each { |(rid, day), n| @days_completed[rid] += 1 if n >= Plan.total_parts(day) }
38
+ @reachable = Reader.where.not(email: [nil, '']).count
39
+ @last_broadcast_at = parsed_time(Setting.read(LAST_BROADCAST_AT))
40
+ @last_broadcast_subject = Setting.read(LAST_BROADCAST_SUBJECT)
35
41
  end
36
42
 
37
43
  def show
@@ -75,6 +81,47 @@ module Bible270
75
81
  end
76
82
 
77
83
  # Put the reader on a given day as of today, or set an explicit start date.
84
+ def broadcast
85
+ subject = params[:subject].to_s.strip
86
+ body = params[:body].to_s.strip
87
+
88
+ if subject.empty? || body.empty?
89
+ return redirect_to(admin_path, alert: 'A message needs both a subject and something to say.')
90
+ end
91
+
92
+ recipients = Reader.where.not(email: [nil, '']).order(:id)
93
+ return redirect_to(admin_path, alert: 'Nobody has an email address.') if recipients.empty?
94
+
95
+ sent = deliver_broadcast(recipients, subject, body)
96
+ Setting.write(LAST_BROADCAST_AT, Time.current.iso8601)
97
+ Setting.write(LAST_BROADCAST_SUBJECT, subject)
98
+
99
+ redirect_to admin_path,
100
+ notice: "Sent to #{sent} #{'reader'.pluralize(sent)}."
101
+ end
102
+
103
+ def update_bible_version
104
+ reader = Reader.find_by(id: params[:id])
105
+ return redirect_to(admin_path, alert: 'No such reader.') if reader.nil?
106
+
107
+ code = params[:bible_version].to_s
108
+ # Blank means "no preference": the reader follows the site default, which is
109
+ # not the same as choosing whichever translation the site happens to use now.
110
+ if code.empty?
111
+ reader.update(bible_version: nil)
112
+ return redirect_to admin_reader_path(reader),
113
+ notice: "#{reader.display_name} now follows the site default " \
114
+ "(#{Bible270.config.bible_version})."
115
+ end
116
+
117
+ if reader.update_bible_version(code)
118
+ redirect_to admin_reader_path(reader),
119
+ notice: "#{reader.display_name} now reads the #{reader.bible_version_label}."
120
+ else
121
+ redirect_to admin_reader_path(reader), alert: 'That is not a translation on the list.'
122
+ end
123
+ end
124
+
78
125
  def update_start
79
126
  if params[:start_date].present?
80
127
  # set_start_date!, not update_start_date!: an admin is not subject to
@@ -138,6 +185,33 @@ module Bible270
138
185
  end
139
186
  end
140
187
 
188
+ # Failures are counted rather than raised: one bad address must not stop the
189
+ # rest of the message going out, and the admin is told how many were sent.
190
+ # A stored timestamp that cannot be read is treated as absent rather than
191
+ # blowing up the admin page.
192
+ def parsed_time(value)
193
+ return nil if value.blank?
194
+
195
+ Time.parse(value.to_s)
196
+ rescue ArgumentError, TypeError
197
+ nil
198
+ end
199
+
200
+ def deliver_broadcast(recipients, subject, body)
201
+ later = Bible270.config.registration_notice_deliver_later
202
+ sent = 0
203
+
204
+ recipients.find_each do |reader|
205
+ mail = NoticeMailer.broadcast(reader_id: reader.id, subject: subject, body: body)
206
+ later ? mail.deliver_later : mail.deliver_now
207
+ sent += 1
208
+ rescue StandardError => e
209
+ Rails.logger.error("[bible270] broadcast to reader #{reader.id} failed: #{e.class}: #{e.message}")
210
+ end
211
+
212
+ sent
213
+ end
214
+
141
215
  private
142
216
 
143
217
  def require_admin!
@@ -36,7 +36,9 @@ module Bible270
36
36
  remember_reader!(reader)
37
37
  @current_reader = reader
38
38
 
39
- redirect_to destination, notice: "Welcome #{reader.first_name}."
39
+ # first_name is filled in for every route now, but a bridged host user may
40
+ # still arrive without one.
41
+ redirect_to destination, notice: "Welcome #{reader.first_name.presence || reader.display_name}."
40
42
  end
41
43
 
42
44
  def destroy
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
4
+ <div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
5
+ <p style="margin:0 0 16px;font-size:15px">
6
+ <%= @reader.first_name.presence || @reader.display_name %>,
7
+ </p>
8
+
9
+ <div style="font-size:15px;line-height:1.6"><%= simple_format(@body) %></div>
10
+
11
+ <p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #d7cfbf;font-size:12px;color:#9a9082">
12
+ <%= @app_name %><% if @plan_url %> ·
13
+ <a href="<%= @plan_url %>" style="color:#a5812c"><%= @plan_url %></a><% end %>
14
+ </p>
15
+ </div>
16
+ </body>
17
+ </html>
@@ -0,0 +1,7 @@
1
+ <%= @reader.first_name.presence || @reader.display_name %>,
2
+
3
+ <%= @body %>
4
+
5
+ --
6
+ <%= @app_name %><% if @plan_url %>
7
+ <%= @plan_url %><% end %>
@@ -31,6 +31,22 @@ module Bible270
31
31
  subject: "#{@app_name}: #{@author.display_name} mentioned you on day #{@comment.day}"
32
32
  end
33
33
 
34
+ # A message from whoever runs the plan to one reader. Sent individually rather
35
+ # than as one email with everyone in bcc: a bcc list of a hundred addresses is
36
+ # a spam signal, and it means nobody can be greeted by name or unsubscribe
37
+ # meaningfully.
38
+ def broadcast(reader_id:, subject:, body:)
39
+ @reader = Reader.find_by(id: reader_id)
40
+ return message.perform_deliveries = false if @reader.nil? || @reader.email.blank?
41
+ return message.perform_deliveries = false if subject.to_s.strip.empty? || body.to_s.strip.empty?
42
+
43
+ @body = body.to_s
44
+ @app_name = Bible270.config.app_name
45
+ @plan_url = plan_url
46
+
47
+ mail to: @reader.email, subject: subject.to_s.strip
48
+ end
49
+
34
50
  private
35
51
 
36
52
  # A mailer has no request, so the host is config.mailer_host or nothing. No
@@ -41,6 +57,10 @@ module Bible270
41
57
  # config.mount_at was worse than useless — ignored when blank, and overriding
42
58
  # the true prefix when not, so a config that disagreed with routes.rb would
43
59
  # have produced links to nowhere.
60
+ def plan_url
61
+ engine_url(:root_url)
62
+ end
63
+
44
64
  def day_url_for(day)
45
65
  engine_url(:day_url, day)
46
66
  end
@@ -45,10 +45,37 @@ module Bible270
45
45
  image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url))
46
46
  reader.email = email if email.present?
47
47
  reader.avatar_url = image if image.present?
48
+ assign_names_from(reader, info)
48
49
  reader.save
49
50
  reader
50
51
  end
51
52
 
53
+ # Providers hand back one display name far more often than two fields, so the
54
+ # name is split when they do not. Without this an OmniAuth reader had no
55
+ # first_name — which made the greeting read "Welcome ." and, more seriously,
56
+ # made them unmentionable: Reader.mentioned_in only considers readers who have
57
+ # one.
58
+ #
59
+ # Only filled in when empty, so a reader who has since corrected their name on
60
+ # their profile does not have it overwritten at every sign-in.
61
+ def self.assign_names_from(reader, info)
62
+ return if reader.first_name.present?
63
+
64
+ given = dig_auth(info, :first_name)
65
+ family = dig_auth(info, :last_name)
66
+
67
+ if given.present?
68
+ reader.first_name = given
69
+ reader.last_name = family
70
+ return
71
+ end
72
+
73
+ split = Names.split_display_name(reader.display_name)
74
+ reader.first_name = split ? split[:first_name] : reader.display_name.to_s.split.first
75
+ reader.last_name = split ? split[:last_name] : nil
76
+ end
77
+ private_class_method :assign_names_from
78
+
52
79
  # Find or create the reader behind a verified email address. Uses the same
53
80
  # provider/uid identity columns as OmniAuth, with provider "email", so an
54
81
  # email reader is indistinguishable from any other downstream.
@@ -47,4 +47,31 @@
47
47
  <p style="color:var(--faint)">No readers yet.</p>
48
48
  <% end %>
49
49
 
50
+ <div class="b270-panel">
51
+ <h2 class="b270-subhead">Write to everyone</h2>
52
+
53
+ <% if @reachable.zero? %>
54
+ <p class="b270-hint" style="margin-top:0">No reader has an email address yet.</p>
55
+ <% else %>
56
+ <p class="b270-hint" style="margin-top:0">
57
+ Goes to <strong><%= @reachable %></strong>
58
+ <%= @reachable == 1 ? 'reader' : 'readers' %> individually, not as one message with
59
+ everyone in bcc — so each is greeted by name.
60
+ <% if @last_broadcast_at.present? %>
61
+ <br>Last sent <%= b270_datetime(@last_broadcast_at) %><%
62
+ %><% if @last_broadcast_subject.present? %>: <em><%= @last_broadcast_subject %></em><% end %>.
63
+ <% end %>
64
+ </p>
65
+
66
+ <%= form_with url: admin_broadcast_path, method: :post, class: "b270-form",
67
+ data: { turbo_confirm: "Send this to #{@reachable} #{@reachable == 1 ? 'reader' : 'readers'}?" } do %>
68
+ <%= text_field_tag :subject, params[:subject], placeholder: "Subject", class: "b270-input" %>
69
+ <%= text_area_tag :body, params[:body], placeholder: "What would you like to say?", rows: 6 %>
70
+ <div class="row">
71
+ <%= submit_tag "Send", class: "b270-btn" %>
72
+ </div>
73
+ <% end %>
74
+ <% end %>
75
+ </div>
76
+
50
77
  <p style="margin-top:18px"><%= link_to '← Back to the plan', root_path %></p>
@@ -92,6 +92,28 @@
92
92
  <%= render "bible270/shared/reflections", comments: @comments, moderate: true %>
93
93
  </div>
94
94
 
95
+ <div class="b270-panel">
96
+ <h2 class="b270-subhead">Which translation they read</h2>
97
+ <p class="b270-hint" style="margin-top:0">
98
+ Reading links open in this translation.
99
+ <% if @reader.bible_version.blank? %>
100
+ They have expressed no preference, so they follow the site default
101
+ (<%= Bible270.config.bible_version %>).
102
+ <% end %>
103
+ </p>
104
+
105
+ <%= form_with url: admin_reader_version_path(@reader), method: :patch, class: "b270-form" do %>
106
+ <div class="row">
107
+ <%= select_tag :bible_version,
108
+ options_for_select([["Site default (#{Bible270.config.bible_version})", ""]] +
109
+ Bible270::Translations.options,
110
+ @reader.bible_version.to_s),
111
+ class: "b270-select", style: "width:auto" %>
112
+ <%= submit_tag "Save", class: "b270-btn" %>
113
+ </div>
114
+ <% end %>
115
+ </div>
116
+
95
117
  <div class="b270-panel">
96
118
  <h2 class="b270-subhead">Remove</h2>
97
119
  <p class="b270-hint" style="margin-top:0">Deletes the reader together with every check-off and
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body style="margin:0;padding:24px;background:#ece6da;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#231f18">
4
+ <div style="max-width:520px;margin:0 auto;background:#f4efe6;border:1px solid #d7cfbf;border-radius:14px;padding:28px">
5
+ <p style="margin:0 0 16px;font-size:15px">
6
+ <%= @reader.first_name.presence || @reader.display_name %>,
7
+ </p>
8
+
9
+ <div style="font-size:15px;line-height:1.6"><%= simple_format(@body) %></div>
10
+
11
+ <p style="margin:24px 0 0;padding-top:16px;border-top:1px solid #d7cfbf;font-size:12px;color:#9a9082">
12
+ <%= @app_name %><% if @plan_url %> ·
13
+ <a href="<%= @plan_url %>" style="color:#a5812c"><%= @plan_url %></a><% end %>
14
+ </p>
15
+ </div>
16
+ </body>
17
+ </html>
@@ -0,0 +1,7 @@
1
+ <%= @reader.first_name.presence || @reader.display_name %>,
2
+
3
+ <%= @body %>
4
+
5
+ --
6
+ <%= @app_name %><% if @plan_url %>
7
+ <%= @plan_url %><% end %>
@@ -183,6 +183,7 @@
183
183
  .b270-comment.b270-editing{background:rgba(165,129,44,.05);border-radius:10px;padding:12px}
184
184
  .b270-editing textarea{min-height:64px}
185
185
  .b270-cdel{font-size:12px;color:var(--oxblood);background:none;border:none;cursor:pointer;padding:0;margin-left:8px}
186
+ .b270-input{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;font:inherit;font-size:14px;margin-bottom:8px}
186
187
  .b270-form textarea{width:100%;font:inherit;font-size:15px;border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:#fff;resize:vertical;min-height:76px}
187
188
  .b270-form .row{display:flex;align-items:center;gap:10px;margin-top:10px}
188
189
  .b270 .b270-btn{border:none;border-radius:10px;padding:8px 16px;margin-top:4px;font:inherit;font-weight:600;font-size:14px;cursor:pointer;background:var(--ink);color:var(--paper);text-decoration:none;display:inline-block}
data/config/routes.rb CHANGED
@@ -22,10 +22,12 @@ Bible270::Engine.routes.draw do
22
22
  delete 'admin/readers/:id', to: 'admin#destroy'
23
23
  patch 'admin/readers/:id/profile', to: 'admin#update_profile', as: :admin_reader_profile
24
24
  delete 'admin/readers/:id/avatar', to: 'admin#remove_avatar', as: :admin_reader_avatar
25
+ patch 'admin/readers/:id/version', to: 'admin#update_bible_version', as: :admin_reader_version
25
26
  patch 'admin/readers/:id/start', to: 'admin#update_start', as: :admin_reader_start
26
27
  patch 'admin/readers/:id/through', to: 'admin#complete_through', as: :admin_reader_through
27
28
  post 'admin/readers/:id/days/:day', to: 'admin#toggle_day', as: :admin_reader_day
28
29
 
30
+ post 'admin/broadcast', to: 'admin#broadcast', as: :admin_broadcast
29
31
  patch 'admin/enrollment', to: 'admin#update_enrollment', as: :admin_enrollment
30
32
  get 'admin/comments', to: 'admin#comments', as: :admin_comments
31
33
  patch 'admin/comments/:id/hide', to: 'admin#hide_comment', as: :admin_hide_comment
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '0.17.1'
4
+ VERSION = '0.17.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bible270
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.1
4
+ version: 0.17.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft
@@ -91,6 +91,8 @@ files:
91
91
  - app/controllers/bible270/sessions_controller.rb
92
92
  - app/helpers/bible270/plan_helper.rb
93
93
  - app/mailers/bible270/application_mailer.rb
94
+ - app/mailers/bible270/broadcast.html.erb
95
+ - app/mailers/bible270/broadcast.text.erb
94
96
  - app/mailers/bible270/notice_mailer.rb
95
97
  - app/mailers/bible270/sign_in_mailer.rb
96
98
  - app/models/bible270/application_record.rb
@@ -119,6 +121,8 @@ files:
119
121
  - app/views/bible270/days/show.html.erb
120
122
  - app/views/bible270/likes/_likes.html.erb
121
123
  - app/views/bible270/likes/toggle.turbo_stream.erb
124
+ - app/views/bible270/notice_mailer/broadcast.html.erb
125
+ - app/views/bible270/notice_mailer/broadcast.text.erb
122
126
  - app/views/bible270/notice_mailer/mentioned.html.erb
123
127
  - app/views/bible270/notice_mailer/mentioned.text.erb
124
128
  - app/views/bible270/notice_mailer/new_reader.html.erb