bible270 0.9.0 → 0.11.0

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -237
  3. data/README.md +159 -79
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +147 -0
  6. data/app/controllers/bible270/application_controller.rb +24 -3
  7. data/app/controllers/bible270/checkoffs_controller.rb +2 -3
  8. data/app/controllers/bible270/comments_controller.rb +6 -2
  9. data/app/controllers/bible270/days_controller.rb +5 -6
  10. data/app/controllers/bible270/profiles_controller.rb +45 -0
  11. data/app/controllers/bible270/readers_controller.rb +5 -4
  12. data/app/controllers/bible270/sessions_controller.rb +89 -26
  13. data/app/helpers/bible270/plan_helper.rb +22 -12
  14. data/app/mailers/bible270/application_mailer.rb +4 -1
  15. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  16. data/app/models/bible270/application_record.rb +1 -0
  17. data/app/models/bible270/checkoff.rb +4 -3
  18. data/app/models/bible270/comment.rb +26 -5
  19. data/app/models/bible270/reader.rb +167 -17
  20. data/app/models/bible270/setting.rb +57 -0
  21. data/app/models/bible270/sign_in_token.rb +5 -2
  22. data/app/views/bible270/admin/comments.html.erb +44 -0
  23. data/app/views/bible270/admin/index.html.erb +48 -0
  24. data/app/views/bible270/admin/show.html.erb +104 -0
  25. data/app/views/bible270/days/_start_date.html.erb +1 -1
  26. data/app/views/bible270/days/index.html.erb +9 -21
  27. data/app/views/bible270/profiles/edit.html.erb +44 -0
  28. data/app/views/bible270/readers/index.html.erb +2 -2
  29. data/app/views/bible270/readers/show.html.erb +5 -5
  30. data/app/views/bible270/sessions/new.html.erb +17 -3
  31. data/app/views/bible270/shared/_day_index.html.erb +12 -0
  32. data/app/views/bible270/shared/_header.html.erb +2 -0
  33. data/app/views/bible270/shared/_styles.html.erb +17 -7
  34. data/app/views/layouts/bible270/application.html.erb +2 -4
  35. data/config/routes.rb +38 -18
  36. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  37. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  38. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  39. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  40. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  41. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  42. data/db/migrate/20260101000007_create_bible270_settings.rb +15 -0
  43. data/lib/bible270/avatars.rb +46 -0
  44. data/lib/bible270/configuration.rb +96 -24
  45. data/lib/bible270/email_sign_in.rb +7 -7
  46. data/lib/bible270/engine.rb +1 -0
  47. data/lib/bible270/names.rb +47 -0
  48. data/lib/bible270/plan.rb +356 -102
  49. data/lib/bible270/versification.rb +84 -67
  50. data/lib/bible270/version.rb +2 -1
  51. data/lib/bible270.rb +8 -5
  52. data/lib/generators/bible270/install/install_generator.rb +521 -38
  53. data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -2
  54. metadata +15 -44
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class DaysController < ApplicationController
4
5
  def index
@@ -19,9 +20,7 @@ module Bible270
19
20
 
20
21
  def show
21
22
  @day = params[:day].to_i
22
- unless Plan.valid_day?(@day)
23
- redirect_to(root_path, alert: "That day is outside the plan.") and return
24
- end
23
+ redirect_to(root_path, alert: 'That day is outside the plan.') and return unless Plan.valid_day?(@day)
25
24
 
26
25
  @readings = Plan.readings_for(@day)
27
26
  @comments = Comment.for_day(@day).includes(:reader)
@@ -30,9 +29,9 @@ module Bible270
30
29
 
31
30
  req = Plan.required_track_count(@day)
32
31
  completer_ids = Checkoff.where(day: @day)
33
- .group(:reader_id)
34
- .having("COUNT(*) >= ?", req)
35
- .pluck(:reader_id)
32
+ .group(:reader_id)
33
+ .having('COUNT(*) >= ?', req)
34
+ .pluck(:reader_id)
36
35
  @completers = Reader.where(id: completer_ids).order(:display_name)
37
36
 
38
37
  @prev_day = @day > 1 ? @day - 1 : nil
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ # A reader editing their own name — the one thing about themselves they can
5
+ # change. Everything else about a reader comes from how they signed in.
6
+ class ProfilesController < ApplicationController
7
+ before_action :require_signed_in_reader
8
+
9
+ def edit
10
+ @names = current_reader.suggested_names
11
+ end
12
+
13
+ def update
14
+ problems = []
15
+ problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
16
+ params[:last_name])
17
+ if params[:avatar].present? && !current_reader.attach_avatar(params[:avatar])
18
+ problems << (current_reader.errors[:avatar].first || 'a valid image')
19
+ end
20
+
21
+ if problems.empty?
22
+ redirect_to reader_path(current_reader), notice: 'Your profile has been updated.'
23
+ else
24
+ @names = { first_name: params[:first_name], last_name: params[:last_name] }
25
+ flash.now[:alert] = "Please give #{problems.to_sentence}."
26
+ render :edit, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def remove_avatar
31
+ current_reader.remove_avatar!
32
+ redirect_to profile_path, notice: 'Your picture has been removed.'
33
+ end
34
+
35
+ private
36
+
37
+ # Unlike checking off a reading, there is nothing sensible to do here for a
38
+ # visitor who isn't signed in, so send them to sign in rather than 404.
39
+ def require_signed_in_reader
40
+ return if signed_in?
41
+
42
+ redirect_to sign_in_path(origin: request.fullpath), alert: 'Please sign in first.'
43
+ end
44
+ end
45
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class ReadersController < ApplicationController
4
5
  def index
@@ -11,7 +12,7 @@ module Bible270
11
12
 
12
13
  def show
13
14
  @reader = Reader.find_by(id: params[:id])
14
- redirect_to(community_path, alert: "Reader not found.") and return unless @reader
15
+ redirect_to(community_path, alert: 'Reader not found.') and return unless @reader
15
16
 
16
17
  @days_completed = @reader.days_completed
17
18
  @recent_comments = @reader.comments.recent.limit(20)
@@ -21,7 +22,7 @@ module Bible270
21
22
  return unless require_reader!
22
23
 
23
24
  unless Bible270.config.allow_reader_start_date
24
- redirect_to(root_path, alert: "The start date is set for the whole community.") and return
25
+ redirect_to(root_path, alert: 'The start date is set for the whole community.') and return
25
26
  end
26
27
 
27
28
  if current_reader.update_start_date!(params[:start_date])
@@ -37,9 +38,9 @@ module Bible270
37
38
 
38
39
  if Bible270.config.allow_reader_start_date
39
40
  current_reader.clear_start_date!
40
- redirect_to root_path, notice: "Start date cleared — the plan is now undated for you."
41
+ redirect_to root_path, notice: 'Start date cleared — the plan is now undated for you.'
41
42
  else
42
- redirect_to root_path, alert: "The start date is set for the whole community."
43
+ redirect_to root_path, alert: 'The start date is set for the whole community.'
43
44
  end
44
45
  end
45
46
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # Built-in OmniAuth sign-in.
4
5
  #
@@ -17,17 +18,17 @@ module Bible270
17
18
  end
18
19
 
19
20
  def create
20
- auth = request.env["omniauth.auth"]
21
- unless auth
22
- redirect_to(sign_in_path, alert: "Sign in didn't complete. Please try again.") and return
21
+ auth = request.env['omniauth.auth']
22
+ redirect_to(sign_in_path, alert: "Sign in didn't complete. Please try again.") and return unless auth
23
+
24
+ if enrollment_closed? && !Reader.omniauth_reader_exists?(auth['provider'], auth['uid'])
25
+ redirect_to(sign_in_path, alert: enrollment_closed_message) and return
23
26
  end
24
27
 
25
28
  reader = Reader.from_omniauth(auth)
26
- unless reader&.persisted?
27
- redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return
28
- end
29
+ redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return unless reader&.persisted?
29
30
 
30
- destination = safe_origin(request.env["omniauth.origin"]) || after_sign_in_path
31
+ destination = safe_origin(request.env['omniauth.origin']) || after_sign_in_path
31
32
 
32
33
  # Rotate the session id to avoid session fixation, then sign in.
33
34
  reset_session
@@ -40,11 +41,11 @@ module Bible270
40
41
  def destroy
41
42
  reset_session
42
43
  @current_reader = nil
43
- redirect_to(after_sign_out_path, notice: "Signed out.")
44
+ redirect_to(after_sign_out_path, notice: 'Signed out.')
44
45
  end
45
46
 
46
47
  def failure
47
- message = params[:message].presence || "unknown error"
48
+ message = params[:message].presence || 'unknown error'
48
49
  redirect_to sign_in_path, alert: "Sign in failed (#{message.to_s.tr('_', ' ')})."
49
50
  end
50
51
 
@@ -63,14 +64,23 @@ module Bible270
63
64
  alert: "That doesn't look like an email address — please check and try again.") and return
64
65
  end
65
66
 
66
- name = Bible270.config.email_sign_in_ask_name ? params[:display_name].to_s.strip : nil
67
- _record, raw = SignInToken.issue!(address, display_name: name)
68
-
69
- if raw
70
- url = email_sign_in_url(token: raw, origin: origin)
71
- minutes = (Bible270.config.email_sign_in_ttl / 60.0).round
72
- mail = SignInMailer.magic_link(email: address, url: url, expires_in_minutes: minutes)
73
- Bible270.config.email_sign_in_deliver_later ? mail.deliver_later : mail.deliver_now
67
+ # Names are optional here. Requiring them only for unknown addresses would
68
+ # make the response differ, which tells a stranger whether an address
69
+ # already has an account. A new reader is asked for their name after they
70
+ # click the link instead (see email_callback).
71
+ names = Names.normalize(params[:first_name], params[:last_name])
72
+
73
+ _record, raw = SignInToken.issue!(address, first_name: names&.dig(:first_name),
74
+ last_name: names&.dig(:last_name))
75
+
76
+ if raw && enrollment_closed? && !Reader.email_reader_exists?(address)
77
+ Rails.logger.info("[bible270] enrolment closed; no link sent to #{address}")
78
+ elsif raw
79
+ deliver_sign_in_link(address, email_sign_in_url(token: raw, origin: origin))
80
+ else
81
+ # Silent to the reader by design; say so in the log or this is undebuggable.
82
+ Rails.logger.warn("[bible270] no sign-in link issued for #{address} " \
83
+ '(rate limited — see config.email_sign_in_max_per_window)')
74
84
  end
75
85
 
76
86
  # Deliberately identical whether or not a link was actually sent, so this
@@ -84,33 +94,86 @@ module Bible270
84
94
  token = SignInToken.claim!(params[:token])
85
95
  if token.nil?
86
96
  redirect_to(sign_in_path,
87
- alert: "That link has expired or was already used. Please request a new one.") and return
97
+ alert: 'That link has expired or was already used. Please request a new one.') and return
88
98
  end
89
99
 
90
- reader = Reader.from_email(token.email, display_name: token.display_name)
91
- unless reader&.persisted?
92
- redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return
100
+ if enrollment_closed? && !Reader.email_reader_exists?(token.email)
101
+ redirect_to(sign_in_path, alert: enrollment_closed_message) and return
93
102
  end
94
103
 
104
+ reader = Reader.from_email(token.email, first_name: token.first_name,
105
+ last_name: token.last_name, display_name: token.display_name)
106
+ redirect_to(sign_in_path, alert: "We couldn't set up your reader profile.") and return unless reader&.persisted?
107
+
95
108
  destination = safe_origin(params[:origin]) || after_sign_in_path
96
109
 
97
110
  reset_session
98
111
  session[:bible270_reader_id] = reader.id
99
112
  @current_reader = reader
100
113
 
114
+ if name_needed?(reader)
115
+ redirect_to(profile_path,
116
+ notice: 'Welcome. Please add your name, so others know who they are reading with.')
117
+ return
118
+ end
119
+
101
120
  redirect_to destination, notice: "Welcome, #{reader.display_name}."
102
121
  end
103
122
 
104
- private
123
+ private
124
+
125
+ # Delivery problems must not leak through the UI (the response is deliberately
126
+ # identical either way), so they are logged rather than raised. Without this a
127
+ # misconfigured mailer either 500s the request or fails silently, depending on
128
+ # config.action_mailer.raise_delivery_errors.
129
+ def deliver_sign_in_link(address, url)
130
+ minutes = (Bible270.config.email_sign_in_ttl / 60.0).round
131
+ mail = SignInMailer.magic_link(email: address, url: url, expires_in_minutes: minutes)
132
+ Bible270.config.email_sign_in_deliver_later ? mail.deliver_later : mail.deliver_now
133
+ Rails.logger.info("[bible270] sign-in email handed to Action Mailer for #{address}")
134
+ rescue StandardError => e
135
+ Rails.logger.error("[bible270] could not deliver the sign-in email to #{address}: #{e.class}: #{e.message}")
136
+ ensure
137
+ log_sign_in_link(address, url)
138
+ end
139
+
140
+ # In development and test, put the link in the log so you can sign in without
141
+ # working mail delivery. Never in production — the link is a bearer token.
142
+ def log_sign_in_link(address, url)
143
+ return unless log_sign_in_link?
144
+
145
+ Rails.logger.info("[bible270] sign-in link for #{address}: #{url}")
146
+ end
147
+
148
+ def log_sign_in_link?
149
+ flag = Bible270.config.email_sign_in_log_link
150
+ return flag unless flag.nil?
151
+
152
+ Rails.env.development? || Rails.env.test?
153
+ end
154
+
155
+ # A reader who signed in by email and still has no first/last name. Applies
156
+ # only to email sign-in: an OmniAuth reader carries whatever name their
157
+ # provider gave.
158
+ def name_needed?(reader)
159
+ Bible270.config.email_sign_in_require_name &&
160
+ reader.provider == 'email' &&
161
+ reader.full_name.nil?
162
+ end
163
+
164
+ def enrollment_closed_message
165
+ 'This run of the plan is closed to new readers. If you have taken part before, ' \
166
+ 'sign in with the same email or account.'
167
+ end
105
168
 
106
169
  # Only ever redirect within this app, and never back into an auth route.
107
170
  def safe_origin(value)
108
171
  value = value.to_s
109
172
  return nil if value.empty?
110
- return nil unless value.start_with?("/")
111
- return nil if value.include?("//") # protocol-relative URL
112
- return nil if value.include?("\\") # browsers may normalise \ to /
113
- return nil if value.include?("/auth/")
173
+ return nil unless value.start_with?('/')
174
+ return nil if value.include?('//') # protocol-relative URL
175
+ return nil if value.include?('\\') # browsers may normalise \ to /
176
+ return nil if value.include?('/auth/')
114
177
 
115
178
  value
116
179
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  module PlanHelper
4
5
  def b270_track(track)
@@ -18,27 +19,36 @@ module Bible270
18
19
  end
19
20
 
20
21
  def b270_passage_url(reference)
21
- return "#" if reference.blank?
22
+ return '#' if reference.blank?
23
+
22
24
  Bible270.config.passage_url_builder.call(reference, Bible270.config.bible_version)
23
25
  end
24
26
 
27
+ # An uploaded avatar wins over whatever the sign-in provider gave us.
28
+ # Active Storage routes live in the host app, hence main_app.
29
+ def b270_avatar_src(reader)
30
+ return main_app.rails_blob_path(reader.avatar, only_path: true) if reader.avatar_uploaded?
31
+
32
+ reader.avatar_url.presence
33
+ end
34
+
25
35
  def b270_avatar(reader, size: 34)
26
- if reader.avatar_url.present?
27
- image_tag reader.avatar_url, class: "b270-avatar", width: size, height: size, alt: reader.display_name
36
+ src = b270_avatar_src(reader)
37
+ if src
38
+ image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name
28
39
  else
29
- content_tag :span, reader.initials, class: "b270-avatar b270-avatar-fallback",
30
- style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
40
+ content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
41
+ style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
31
42
  end
32
43
  end
33
44
 
34
45
  def b270_day_status_class(reader, day)
35
- return "" unless reader
36
- if reader.day_complete?(day)
37
- "complete"
38
- elsif reader.read_tracks_for(day).any?
39
- "partial"
40
- else
41
- ""
46
+ return '' unless reader
47
+
48
+ case reader.day_status(day)
49
+ when :complete then 'complete'
50
+ when :partial then 'partial'
51
+ else ''
42
52
  end
43
53
  end
44
54
  end
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class ApplicationMailer < ActionMailer::Base
4
5
  default from: -> { Bible270.config.mailer_from }
5
- layout nil
6
+ # `layout nil` means "normal lookup", which finds the host app's mailer layout and
7
+ # nests our complete HTML document inside theirs. Only `false` disables it.
8
+ layout false
6
9
  end
7
10
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class SignInMailer < ApplicationMailer
4
5
  # The URL is built by the controller (which has the request context), so the
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  class ApplicationRecord < ActiveRecord::Base
4
5
  self.abstract_class = true
@@ -1,17 +1,18 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # A single reader marking one track (ot / nt / pp) read on one day.
4
5
  class Checkoff < ApplicationRecord
5
- self.table_name = "bible270_checkoffs"
6
+ self.table_name = 'bible270_checkoffs'
6
7
 
7
- belongs_to :reader, class_name: "Bible270::Reader"
8
+ belongs_to :reader, class_name: 'Bible270::Reader'
8
9
 
9
10
  validates :day, inclusion: { in: 1..Plan::DAYS }
10
11
  validates :track, inclusion: { in: Plan::TRACKS.keys }
11
12
  validates :track, uniqueness: { scope: %i[reader_id day] }
12
13
  validate :track_present_on_day
13
14
 
14
- private
15
+ private
15
16
 
16
17
  def track_present_on_day
17
18
  return if day.nil? || track.nil?
@@ -1,16 +1,37 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Bible270
3
4
  # A publicly visible reflection attached to a plan day (optionally a track).
4
5
  class Comment < ApplicationRecord
5
- self.table_name = "bible270_comments"
6
+ self.table_name = 'bible270_comments'
6
7
 
7
- belongs_to :reader, class_name: "Bible270::Reader"
8
+ belongs_to :reader, class_name: 'Bible270::Reader'
8
9
 
9
10
  validates :day, inclusion: { in: 1..Plan::DAYS }
10
11
  validates :body, presence: true, length: { maximum: 4000 }
11
- validates :track, inclusion: { in: Plan::TRACKS.keys }, allow_nil: true
12
+ # A reflection can be about one track or about the whole day. The form's
13
+ # "The whole day" option submits an empty string, so normalise it to NULL
14
+ # before validating — allow_nil alone rejects "" with
15
+ # "Track is not included in the list".
16
+ before_validation :normalize_track
17
+
18
+ validates :track, inclusion: { in: Plan::TRACKS.keys }, allow_blank: true
19
+
20
+ scope :approved, -> { where(approved: true) }
21
+ scope :hidden, -> { where(approved: false) }
22
+ scope :for_day, ->(d) { approved.where(day: d).order(created_at: :asc) }
23
+ scope :recent, -> { approved.order(created_at: :desc) }
24
+
25
+ # Every reflection is visible when written; these are the moderation actions.
26
+ def hide! = update!(approved: false, moderated_at: Time.current)
27
+ def unhide! = update!(approved: true, moderated_at: Time.current)
28
+ def hidden? = !approved
29
+ def moderated? = moderated_at.present?
30
+
31
+ private
12
32
 
13
- scope :for_day, ->(d) { where(day: d).order(created_at: :asc) }
14
- scope :recent, -> { order(created_at: :desc) }
33
+ def normalize_track
34
+ self.track = Plan.normalize_track(track)
35
+ end
15
36
  end
16
37
  end